Uh oh!
There was an error while loading. Please reload this page.
fix(2324): refuse an unknown Status in .kanban.yml, not Cancel the card - #302
Conversation
`resolve()` returned a `.kanban.yml` override's Status verbatim. An unknown BRANCH was refused; an unknown STATUS was not. That asymmetry is what made a typo destructive: the name reaches kanban-closure-router's update step, resolves to no ProjectV2 option id, and the step aborts WITHOUT WRITING -- at which point the project's built-in "Item closed" automation sets `Cancelled` and kanban-archive.yml takes the card off the board within a day. It fell straight through the `unreadable_override` holding state that .github#295 added for the neighbouring case. An override that cannot be READ was parked; one that read fine and named a column that does not EXIST was silently destroyed. Both now refuse, so both reach every caller as the non-zero exit all three already handle. Membership of ENV_FOR_STATUS is the accept list -- one dict supplies both the acceptance and the environment, so they cannot drift apart, and kanban-columns-check.py already asserts every key exists on the board. Also: * the mapper's stderr is no longer discarded at any of the three call sites. The refusal names the branch, the bad value and the accepted vocabulary, and that message is the only thing telling an operator which line to fix. * `override-unreadable` -> `override-unusable`. Two causes reach the holding state now; a label claiming a read failure that never happened sends the operator to the wrong place. No card has ever carried the old name. Refs tracebloc/backend#2324, .github#295, backend#2243 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 04136cf. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
…it at 100 Bugbot on #302, and it is right. GitHub's label API 422s over 100 characters. The create runs under `set -euo pipefail`, so an over-long description aborts the step and the parked card never gets the marker -- the holding state losing the one thing that makes it visible to the weekly pass, which is the failure this PR exists to prevent, reintroduced by its own wording. The baseline description was 80 and mine was 133; the replacement is 83. Measured now rather than eyeballed: the selftest extracts every `-f description=` in the router and asserts the cap, and fails closed when it can extract none. Refs tracebloc/backend#2324 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
saadqbal
left a comment
There was a problem hiding this comment.
Green, no open threads, and the load-bearing claims check out when read rather than taken. This is the right fix for the right reason.
The asymmetry is the finding, and you named it exactly. An unknown branch refused; an unknown Status passed through verbatim. And it fell straight through the holding state #295 added for the neighbouring case — an override that could not be read got parked, one that read fine and named a column that does not exist got destroyed. Same class, opposite treatment, and the destructive half was the one nobody had looked at. That the exposure was never the environment but always the Status is the part the earlier reasoning got backwards, and saying so in resolve's docstring rather than quietly changing it is worth more than the fix.
The accept list is derived, and I verified the link rather than trusting it.resolve() refuses on want not in ENV_FOR_STATUS (:115), and kanban-columns-check.py:103 does from branch_status_map import DEFAULT_MAP, ENV_FOR_STATUS and asserts each key exists on the live board. So the accept list can't become a hand-written restatement of the board's vocabulary — the thing that gates acceptance is the thing that gets checked against reality. One dict yielding both answers, so acceptance and environment cannot drift the way two lists would.
The tests clear the house bar, including the part almost everyone skips:
# AN INERT CASE AND A WORKING GUARD LOOK IDENTICAL. A mutant that collides with a# real declared name would be silently testing acceptance, so assert the domain is# genuinely outside the table before asserting anything about it._cases= [cforcin_casesifcnotinENV_FOR_STATUS]
eq("the refusal cases are derived from every declared Status",
len(_cases) >=len(ENV_FOR_STATUS) *4, True)Filtering colliding mutants out is the obvious half; the floor assertion so the filter can't silently empty the set is the half that makes it a guard rather than a gesture. Same shape on the stderr check — mapper call sites located asserted before stderr is not discarded, so a broken locator can't report a clean sweep over nothing.
Asserting the specific refusal rather than any non-zero exit is also right, given read_override raises SystemExit for several other reasons.
The #295 alias is now fully retired — I checked all four sites
I flagged on #295 that Staging (human review) was accommodated (via opt_either) rather than retired. At this head every remaining mention is an explanatory comment and no live path accepts it:
branch_status_map.py— deliberately absent, with the measured board option list recordedadvance-deploy-env.yml:251— therank()entry removed, "that instant has passed"kanban-columns-check.py:22—opt_either's call gone, only the historical note leftkanban-closure-router.yml:378— a note about the list that had rotted
Retiring a shim by measuring the board rather than by assuming the rename finished is the right way round.
Two consequences worth having fixed with it
Stopping the stderr discard at all three call sites matters more here than it did before: when the only refusal was "could not fetch", the label carried the whole story. An unknown-Status refusal names the branch, the bad value and the accepted vocabulary — and swallowed, the card parks with the reason nowhere. And override-unreadable → override-unusable is the kind of rename that only looks cosmetic: a card can now reach the holding state with a .kanban.yml that read perfectly, so a label claiming a read failure sends the operator to the wrong file.
"Still unreachable today (no repo has a .kanban.yml — 19 checked), so nothing is broken right now. It fires on the first adopter" is the honest framing, and the right reason to fix it before the adopter arrives rather than after.
Approving.
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 22, 2026
/fr-pass |

Fixes the High Bugbot finding filed as
tracebloc/backend#2324, raised on the
release-train prod mirror #301
(thread). Fixed on
developper the ticket, not on the promotion PR.The defect, reproduced before fixing
resolve()returned the override's Status verbatim. An unknown branch was refused;an unknown Status was not. Ran both halves locally before touching anything — the
mapper, then the router's own
STATUS_OPTblock extracted from the YAML and driven withthe live board's option list:
Exit 1 without writing is the whole bug. The project's built-in Item closed
automation then decides instead and sets
Cancelled;kanban-archive.ymlarchivesterminal columns daily. A typo in the first adopter's
.kanban.ymldoes not produce anerror on the card — it marks the work cancelled and hides it within 24h.
And it fell straight through the
unreadable_overrideholding state #295 added for theneighbouring case: an override that could not be read was parked, one that read fine
and named a column that does not exist was destroyed. Opposite treatment for the same
class.
Still unreachable today (no repo has a
.kanban.yml— 19 checked), so nothing is brokenright now. It fires on the first adopter.
The fix
Membership of
ENV_FOR_STATUSis the accept list. Anything else refuses at thesource, so the failure reaches all three callers as the non-zero exit they already
handle — router parks + labels, reconcile skips, advance-deploy-env fails red and the
card keeps what it had. It used to reach them as a successful answer, which is why none
of them handled it.
One dict, two answers, deliberately: a Status is accepted because it has a declared
environment, so acceptance and env cannot drift apart the way two lists would. And
kanban-columns-check.pyalready imports these keys and asserts each exists on the board— that is what keeps the accept list from being a hand-written restatement of the board's
vocabulary. Verified against the live board on this branch:
Two consequences, fixed with it
nothing while the only refusal was "could not fetch" — the flag said that much. The
unknown-Status refusal names the branch, the bad value and the accepted vocabulary, and
that message is the only thing telling an operator which line to fix. Swallowed, the
card is parked with no reason anywhere. Now machine-checked.
override-unreadable→override-unusable. The label is the operator's entrypoint, and a card can now reach the holding state with a
.kanban.ymlthat readperfectly. A label claiming a read failure that never happened sends them to the wrong
place. No card has ever carried the old name (no repo has a
.kanban.yml), so there isnothing to migrate.
Tests — derived from the vocabulary, not from a bad string
The ticket asked for this specifically, so a future column rename cannot re-open it.
branch-status-map-selftest.pygoes 50 → 151 assertions:ENV_FOR_STATUSdeclares × every branchDEFAULT_MAPdeclares— all of it, not a sample. A column added to the table is exercised with no edit here.
trailing space) plus retired column names, each filtered against the live table first
so a mutant colliding with a real name cannot sit there inert.
read_overrideraisesSystemExitfor several other reasons, so the marker is asserted, and the message is asserted to
name the branch, the value and the full vocabulary.
being asserted clean.
unreadableidentifier is gone from the router's code lines (commentsexplaining the rename necessarily quote it — the e2e#176 trap), and that scan is shown
able to catch a reinstated one.
Bugbot finding below.
Mutation-proved, each anchor asserted applied
resolve()restored verbatim'FR on dev' was accepted as a Statusgot (1, False), want (1, True)2>/dev/nullrestored at a router call siteoverride-unreadable-f description=renamed so the extractor finds nothingTwo further mutations came back still green and were discarded as unfaithful rather
than papered over:
ENV_FOR_STATUS[want]→.get(want, env)is unreachable while theguard stands (the combined pre-fix mutation above is the honest test of it), and my first
"drop the marker" attempt appended a variable instead of changing the message — an inert
mutation and good coverage look identical, which is the reason the harness asserts the
anchor landed.
Bugbot on this PR — one finding, confirmed and fixed
Label description exceeds API limit(Medium), and it was right. My newoverride-unusabledescription was 133 characters against the label API's cap of100; the baseline was 80. The create runs under
set -euo pipefail, so the 422 abortsthe step and the parked card never gets the marker — this PR's own fix losing the
visibility it exists to add. Replacement is 83 chars, and the selftest now measures
every
-f description=in the workflow rather than leaving it to the eye(1eb2dd1).
Verification
Notes for the reviewer
ENV_FOR_STATUSand not a live board read. The ticketsuggests validating against the board's option list.
advance-deploy-env.ymlcalls themapper with
GH_TOKEN: ${{ github.token }}— deliberately, since it reads.kanban.ymlfrom the caller's repo — and that token cannot read an org ProjectV2 (every board
writer here mints an App token for exactly this reason). A board read at that call site
would fail-close and turn every adopter's push red. Validating against the table that
kanban-columns-check.pyalready pins to the board gets the same guarantee with no newtoken, no new network dependency, and a pure function the selftest can drive offline.
kanban-columns-check.py's board job is not a required context ondevelop(
actionlint,gate,quality/*,selftests). It runs on PRs touchingbranch_status_map.pyand daily. Pre-existing, not touched here — but it is the checkthe accept-list's board-agreement rests on, so worth a decision either way.
Refs tracebloc/backend#2324, #295, backend#2243
🤖 Generated with Claude Code