Skip to content

feat(kanban): a bug-labelled issue lands in Ready, not Backlog (backend#2348) - #309

Closed
LukasWodka wants to merge 1 commit into
developfrom
feat/2348-bug-label-lands-in-ready
Closed

feat(kanban): a bug-labelled issue lands in Ready, not Backlog (backend#2348)#309
LukasWodka wants to merge 1 commit into
developfrom
feat/2348-bug-label-lands-in-ready

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2348.

The org rule — "bugs get work-type:bug and go straight into Ready (defects skip refinement)" — is written in CLAUDE.md, in org-standards.md and in every repo's contributor guidance, and nothing implemented it. add-to-kanban.yml adds every new issue at Backlog, and no workflow read the label afterwards.

Measured 2026-08-22: seven bug-labelled tickets filed in one day — backend#2324, #2327, #2329, #2340, #2341, #2344 and frontend-app#871 — all seven landed in Backlog and needed a hand-run updateProjectV2ItemFieldValue. A 100% miss rate is the tell that nothing does it at all; a rule people mostly follow produces a mixed record. Ready is the queue engineers pull from and Backlog is the refinement queue nobody pulls from, so a defect filed correctly and labelled correctly was invisible to the people meant to pick it up. The release train's deferral story rides the same path: it files a ticket for every Medium/Low it ships as "a record rather than a dismissal", and those landed unread too.


Design choice: a new reusable, not a line in add-to-kanban.yml

add-to-kanban.yml is the obvious place — every issue already passes through it — and it is the wrong place, for two independent reasons.

1. It is the org's only per-repo COPY, and copies are byte-compared. copies: in repo-inventory.yml is a list of exactly one entry, and caller-drift.py compares each repo's copy against this repo's by git blob sha:

{name}: {copy_name} has DRIFTED from the copy in {org}/.github
(blob {actual} vs {source}). It is a copy, not a caller, so nothing else
would ever notice.

2. Concern separation, for the tickets already queued behind it. add-to-kanban.yml answers "is this on the board"; this answers "which column does the label imply". backend#1877 (cross-repo PR surfacing) and backend#2157 (token scoping) both have that file in their path, and a second concern welded into it is a second concern they have to carry.

The fleet-ordering finding — stated plainly

Editing add-to-kanban.yml here is not an edit to one file; it is an edit to nineteen. The moment a modified canonical copy is on .github's audit branch, all eighteen other repos' copies go DRIFTED and the Monday caller-drift audit is red until the last repo's PR lands. The workspace CLAUDE.md already records the rule this implies: "a fleet-wide bump must merge every other repo before .github, or the org audit goes red on main until the last one lands."

For a new feature that ordering is not merely awkward, it is impossible: the canonical text has to exist somewhere before it can be copied, and the only somewhere is this repo. So the choice was between a guaranteed red-audit window across the fleet, or a mechanism with no byte comparison at all. A reusable is resolved from @main at run time — the fleet picks up a change with no per-repo edit and nothing to compare. That is a real argument for the reusable and it is the deciding one.

What this PR does cost, and how it is paid. A reusable still needs one thin caller per repo, and that rollout is a second PR. The order is forced by this file's own header (".github's caller state is read from its audit branch over the API, so adding a caller and flipping its entry to required in one PR fails"), so the reusable lands with zero callers and every repo's row is exempt with a written reason — the same two-step stale_backlog_migration_in_flight and blocked_gate_rollout_pending already document. The anchor says out loud that if it is still there after the rollout it has become a defect, not a justification. The rollout PR flips all nineteen rows to required and adds the README onboarding line; adding that line now would point a new-repo onboarder at a caller file no repo has.

Verified: caller-drift.py against the live org produces zero findings mentioning bug-to-ready.yml. (It reports 16 pre-existing findings, all stale-backlog.yml is marked exempt but a caller exists — that migration's callers have landed and its inventory flip is overdue. Untouched here, but somebody should close it.)

The monotonicity guarantee

A card is promoted only from Backlog, or from no Status at all. Nothing else moves, ever.

  • promote — the card is at Backlog, or is on the board with no Status. Those are the only two states from which moving to Ready is forward. A card with no Status is not "past Ready"; leaving it unplaced keeps it invisible, which is the complaint.
  • hold — anywhere else, green and logged. The label arrives at any point in a card's life and usually arrives after triage has moved it. In progress, On dev, FR on staging, Prod, Done, Cancelled — all left alone.
  • noboard — refuses loudly when the anchors are missing or when Ready does not sit strictly after Backlog on the live board. That second half is the monotonicity assertion: drag the Status options so Ready precedes Backlog and this workflow's "promotion" becomes a demotion, so it must refuse rather than perform it. Existence was checked and ORDER was not in the sibling classifier until backend#1994; that hole is not reproduced here, and the selftest drives the inverted board including the card sitting at Backlog — the one case that would actually perform the demotion if the anchor check ran second.

Positions come from the board's own option order, via the same col_index shape kanban-closure-router.yml uses. No rank table is held here: a second copy of the column order agrees with itself while disagreeing with reality (backend#1729 rule 1).

Also per the constraints: issues only (a pull_request payload is refused with or without the label — per the board's model an issue occupies Backlog/North Stars/Ready and the flow columns are for PRs), triggers on both opened and labeled at the caller (the Bug template applies the label at creation; a later label is the common case), and it fails closed everywhere — an unreadable labels payload, a card not on the board after 5×5s, a project with no readable Status field, and an unplaceable current column are all red runs, never a green no-op. That last one is deliberately louder than the sibling router's quiet unknown, and the file says why: there, declining is the conservative end state; here it means the monotonicity proof could not be made from a read that should have been atomic.

Token scoping

Not a twelfth unscoped mint (backend#2157). permission-issues: read + permission-organization-projects: write, repositories: narrowed to the calling repo, job permissions: {} — derived from what the step actually runs. mint-scope counts it among the scoped ones.

Evidence

make checkexit 0.

ruff check --isolated --select E4,E7,E9,F .
shellcheck: clean
house-rules: no findings across 7 file(s).
action-pins: 34 file(s) scanned, 0 finding(s)
mint-scope: 15 `actions/create-github-app-token` step(s) across 34 workflow file(s)
  no findings (exemptions all still apply)
actionlint: 0 findings
selftests-cover: all 14 selftests and 2 mutation runner(s) are wired to a target, and CI runs both tiers

kanban-columns-check.py against the live board — the ticket asked for exactly this, a test against the board's option list rather than a hardcoded name:

  ok Backlog              <- advance-deploy-env.yml, bug-to-ready.yml
  ok Ready                <- advance-deploy-env.yml, bug-to-ready.yml
All 12 written Status name(s) exist on the board.

The new suite — 37 assertions, and the decision under test is extracted from the YAML by its # selftest: markers and run verbatim, never re-implemented (.github#114/#115 twice proved an inline copy goes on proving a regex nothing uses would have caught the bug):

PASS  a card at 'Backlog' is promoted
PASS  a card at '<no Status>' is promoted
PASS  of all 12 board columns, exactly 'Backlog' promotes
        promoting: ['Backlog']; every other column holds
PASS  a card already at 'In progress' is left alone
PASS  a card already at 'Prod' is left alone
PASS  a card already at 'Done' is left alone            (…all 11 non-Backlog columns)
PASS  a PR carrying the bug label is refused, label or not
PASS  labels that are not JSON fails the run rather than reading as 'no'
PASS  'work-type:bugfix' is not 'work-type:bug'
PASS  a board whose Backlog/Ready order is INVERTED refuses
PASS  the inverted board refuses even for a card at 'Backlog'

37 passed, 0 failed

The board's column order is parsed out of kanban-deploy-state-selftest.py rather than written down a second time, and the whole vocabulary is driven through the gate — mutation coverage cannot see a vocabulary gap (backend#1729 rule 6).

Mutation-proved, every anchor asserted applied (an inert mutation and good coverage produce an identical green log):

BASELINE (unmutated): rc=0  37 passed, 0 failed

CAUGHT    the monotonic guard: promote from ANY column, not just Backlog
    -> rc=1  25 passed, 12 failed
CAUGHT    the anchor ORDER half: check existence only, not that Ready follows Backlog
    -> rc=1  35 passed, 2 failed
CAUGHT    the unplaceable-column arm: treat an unknown column as promotable
    -> rc=1  36 passed, 1 failed
CAUGHT    the pull-request refusal
    -> rc=1  36 passed, 1 failed
CAUGHT    the `issues`-event-only refusal
    -> rc=1  36 passed, 1 failed
CAUGHT    exact label matching: match a SUBSTRING instead of the whole name
    -> rc=1  34 passed, 3 failed
CAUGHT    fail-closed on unreadable labels: read them as 'not a bug' instead
    -> rc=1  35 passed, 2 failed
CAUGHT    the no-Status promotion: leave an unplaced card unplaced
    -> rc=1  35 passed, 2 failed

RESTORED: rc=0  37 passed, 0 failed
8 mutation(s): 0 stale-or-uncaught

And the extraction itself fails loudly rather than testing a stale copy:

drop the gate marker      -> rc=1: could not find the # selftest:gate-* region in bug-to-ready.yml
drop the candidate marker -> rc=1: could not find the # selftest:candidate-* region in bug-to-ready.yml

What the selftest does NOT cover

Named, because an unstated gap is how a suite comes to be trusted for more than it proves:

  • No network seam is exercised. The GraphQL read, the retry loop, the item-not-on-board fail-closed and the updateProjectV2ItemFieldValue write are not driven — the suite runs the two decision regions with a stubbed col_index and stubbed payloads. The write path is asserted only by inspection and by the shape of the sibling workflows it copies.
  • The caller side is untested and, today, absent. Nothing here proves that issues: [opened, labeled] reaches this reusable, because no caller exists yet. That evidence is the rollout PR's to produce, and it is a landed card — a green mint proves nothing (the lesson add-to-kanban.yml's own comment block records).
  • The opened/labeled double-fire is reasoned about, not measured. Both runs compute the same write and the concurrency group serialises them; no test drives two concurrent runs.
  • unknown is defence in depth, not a live path. The workflow's single atomic query is what makes a card's Status always a name the options list contains. The arm exists so the case has no fall-through and is driven against a synthetic board; nobody has produced it from a real one.
  • The board order is trusted to be pipeline order. Same assumption kanban-closure-router.yml already makes; this PR adds the inverted-anchor refusal for the two columns it cares about, not a general proof.

CI status — the two red checks are pre-existing, not this PR

audit (caller-drift) and gate (conformance-gate) are red. gate fails because audit did — it requires a passing audit for any contract change and does nothing else. So there is one failure, not two.

audit reports FINDINGS_TOTAL: 16, and all sixteen are the same finding:

$ gh run view --job 97091676299 --log \
    | grep -oE "bug-to-ready[^ ]*|stale-backlog.yml is marked .exempt. but a caller exists" \
    | sort | uniq -c
  16 stale-backlog.yml is marked `exempt` but a caller exists

Zero mentions of bug-to-ready.yml anywhere in the audit log. This is the backend#1979 rollout mid-flight: its per-repo callers have been landing through today while the inventory flip (ci/1979-arm-the-sweep) is still open, and every landed caller adds one finding until it merges. The same job was red with 2 of these findings at 18:40 today on the unrelated fix/2242-rfcs-exemption-reasons, and with 16 by 20:51 — it is counting the rollout, not this branch. This PR's inventory diff touches stale-backlog on exactly one line, and that line is prose in the new anchor naming the sibling anchor.

Reproduced locally on this branch before pushing: same 16, same zero. This PR adds no drift finding. It goes green the moment ci/1979-arm-the-sweep lands; it needs nothing from here.

Everything else is green, Bugbot included.

🤖 Generated with Claude Code

…nd#2348)

The org rule "bugs get `work-type:bug` and go straight into `Ready` (defects
skip refinement)" was documented in CLAUDE.md, org-standards.md and every
repo's contributor guidance, and implemented nowhere. `add-to-kanban.yml`
adds every new issue at `Backlog`; no workflow read the label afterwards.

Measured 2026-08-22: seven bug-labelled tickets filed in one day (backend#2324,
#2327, #2329, #2340, #2341, #2344, frontend-app#871) all landed in `Backlog` and
needed a hand-run `updateProjectV2ItemFieldValue`. A 100% miss rate is the tell
that nothing does it at all, rather than that people forget sometimes.

A NEW REUSABLE, not a line in `add-to-kanban.yml`. That file is the org's only
per-repo COPY, byte-compared against this repo's by `caller-drift.py`, so an
edit here makes all 18 other copies read DRIFTED on the next Monday audit --
and the documented escape (merge every other repo BEFORE .github) is impossible
for a change that must be authored here first. A reusable resolves from `@main`
at run time, so there is no byte comparison and no ordering trap. It also keeps
`add-to-kanban.yml` free for backend#1877 and backend#2157, which both have it
in their path.

MONOTONIC BY CONSTRUCTION. It promotes only from `Backlog` or no-Status, and it
refuses unless the live board reports `Ready` strictly AFTER `Backlog` -- so a
reordered board makes the "promotion" a demotion and the workflow says so rather
than performing it. Positions come from the board's own option order, never from
a rank table held here. A card at `In progress`, `On dev`, `Prod`, `Done` or
`Cancelled` is left where it is.

Ships with ZERO callers, deliberately, for exactly one PR: the decision surface
is reviewed on its own and the callers follow, which is the two-step
`stale_backlog_migration_in_flight` and `blocked_gate_rollout_pending` already
document. Every repo's inventory row says so in writing.

Evidence: `make check` green (actionlint 0, shellcheck clean, house-rules clean,
action-pins clean, mint-scope clean -- the new mint is scoped, not a twelfth
unscoped one). 37 new assertions, 8 mutations all caught with their anchors
asserted. `caller-drift.py` against the live org: 0 findings mentioning
bug-to-ready.yml. `kanban-columns-check.py` against the live board now collects
`Backlog` and `Ready` from this workflow and confirms both exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka LukasWodka self-assigned this Aug 22, 2026
@LukasWodka
LukasWodka requested a review from saadqbal August 22, 2026 20:51

@saadqbal saadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding on the red, which again isn't this diff's fault, and flagging one thing I like a lot.

The red is collateral. audit reports 16 findings and every one reads <repo>: stale-backlog.yml is marked \exempt` but a caller exists (stale-backlog-caller.yml). The exemption is stale— averaging-service, backend, claude-skills, cli, client and the rest of the wave. That's the transient window.github#307argues for, now at full width because the wave's callers have landed;.github#293's flip closes it. Since audit` is fleet-wide, every PR in this repo is red until then, so the sequence is #307#293 → re-run here. Nothing to fix on your side.

The substance is enforcing a rule that already existed and was being missed 100% of the time. The org standard is explicit — bugs get work-type:bug and go straight to Ready, because defects don't wait for refinement — and the measurement (seven bug-labelled tickets in one day, every one left in Backlog and hand-moved) is the right kind of justification: not "this would be tidier" but "the documented rule has never once been followed by the automation". Framing it as Ready being the queue engineers pull from, so a correctly-filed and correctly-labelled bug was invisible by default, is the consequence that makes it worth a workflow rather than a reminder.

The part I'd single out is registering bug-to-ready.yml in kanban-columns-check.py in the same PR, with the reason stated: Ready and Backlog are load-bearing in opposite directions, so a board rename turns this workflow into either a red run or a permanent no-op. A new workflow that reads two column names and doesn't join the cross-check is exactly how the vocabulary drifts silently — and a permanent no-op is the failure mode nobody notices, which is the same argument #304 makes about a sweep arm that skips. Adding it now rather than after the first rename is the difference between a guard and an incident report.

Also right: bug-label as an input rather than a setting, with Ready deliberately not configurable. A repo that could configure the destination away could opt out of the standard, which would defeat the point.

To be straight about scope — I've read the rule, the trigger reasoning (opened + labeled, because the template applies the label at creation) and the column-check registration, not all 347 lines line-by-line. Re-request me when it's green and I'll finish the pass over the workflow body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants