Skip to content

fix(2324): refuse an unknown Status in .kanban.yml, not Cancel the card - #302

Merged
LukasWodka merged 2 commits into
developfrom
fix/2324-override-status-vocabulary
Aug 21, 2026
Merged

fix(2324): refuse an unknown Status in .kanban.yml, not Cancel the card#302
LukasWodka merged 2 commits into
developfrom
fix/2324-override-status-vocabulary

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes the High Bugbot finding filed as
tracebloc/backend#2324, raised on the
release-train prod mirror #301
(thread). Fixed on
develop per 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_OPT block extracted from the YAML and driven with
the live board's option list:

=== 1. the mapper, with a .kanban.yml naming a Status the board does not have ===
override develop -> 'Pord' => ('Pord', 'dev')
override develop -> 'Staging (human review)' => ('Staging (human review)', 'dev')
override develop -> 'FR on dev' => ('FR on dev', 'dev')
=== 2. what the router's update step then does with that name ===
wrote Status=On dev (option o5)
-> STATUS_NAME=On dev : update step exit 0
Could not resolve Status option 'Pord' — aborting
-> STATUS_NAME=Pord : update step exit 1

Exit 1 without writing is the whole bug. The project's built-in Item closed
automation then decides instead and sets Cancelled; kanban-archive.yml archives
terminal columns daily. A typo in the first adopter's .kanban.yml does not produce an
error on the card — it marks the work cancelled and hides it within 24h.

And it fell straight through the unreadable_override holding state #295 added for the
neighbouring 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 broken
right now. It fires on the first adopter.

The fix

Membership of ENV_FOR_STATUS is the accept list. Anything else refuses at the
source, 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.py already 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:

 ok FR on staging <- advance-deploy-env.yml, branch_status_map.py, fr-pass-comment.yml
ok On dev <- advance-deploy-env.yml, branch_status_map.py, kanban-closure-router.yml
ok Prod <- ..., branch_status_map.py, ...
ok Ready for prod <- advance-deploy-env.yml, branch_status_map.py, fr-pass-comment.yml
ok Staging (agent review) <- advance-deploy-env.yml, branch_status_map.py
All 12 written Status name(s) exist on the board.

Two consequences, fixed with it

  • The mapper's stderr is no longer discarded at any of the three call sites. It cost
    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-unreadableoverride-unusable. The label is the operator's entry
    point, and a card can now reach the holding state with a .kanban.yml that read
    perfectly. 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 is
    nothing 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.py goes 50 → 151 assertions:

  • Accepted: every key ENV_FOR_STATUS declares × every branch DEFAULT_MAP declares
    — all of it, not a sample. A column added to the table is exercised with no edit here.
  • Refused: the same keys mutated (case-folded, despaced, truncated, pluralised,
    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.
  • The specific refusal, not any exit (rule 10): read_override raises SystemExit
    for several other reasons, so the marker is asserted, and the message is asserted to
    name the branch, the value and the full vocabulary.
  • No call site may discard the mapper's stderr — with the sites asserted found before
    being asserted clean.
  • The old unreadable identifier is gone from the router's code lines (comments
    explaining the rename necessarily quote it — the e2e#176 trap), and that scan is shown
    able to catch a reinstated one.
  • Every label description the router creates fits the API's 100-character cap — see the
    Bugbot finding below.

Mutation-proved, each anchor asserted applied

mutationresult
pre-fix resolve() restored verbatimREDDENS — 34 fail, 'FR on dev' was accepted as a Status
guard inverted (nothing accepted)REDDENS
refusal message diverges from its markerREDDENS — 67 fail, got (1, False), want (1, True)
refusal stops listing the vocabularyREDDENS — 33 fail
2>/dev/null restored at a router call siteREDDENS
label reverted to override-unreadableREDDENS
133-char label description restoredREDDENS
-f description= renamed so the extractor finds nothingREDDENS (fails closed)

Two 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 the
guard 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 new
override-unusable description was 133 characters against the label API's cap of
100; the baseline was 80. The create runs under set -euo pipefail, so the 422 aborts
the 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

make check exit=0
ruff clean · shellcheck clean · actionlint 0 findings
67 / 51 / 34 / 151 / 27 / 41 / 13 / 41 passed, 0 failed
python3 scripts/kanban-columns-check.py All 12 written Status name(s) exist on the board.

Notes for the reviewer

  • Why the accept list is ENV_FOR_STATUS and not a live board read. The ticket
    suggests validating against the board's option list. advance-deploy-env.yml calls the
    mapper with GH_TOKEN: ${{ github.token }} — deliberately, since it reads .kanban.yml
    from 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.py already pins to the board gets the same guarantee with no new
    token, 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 on develop
    (actionlint, gate, quality/*, selftests). It runs on PRs touching
    branch_status_map.py and daily. Pre-existing, not touched here — but it is the check
    the accept-list's board-agreement rests on, so worth a decision either way.

Refs tracebloc/backend#2324, #295, backend#2243

🤖 Generated with Claude Code

`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>
@LukasWodkaLukasWodka self-assigned this Aug 21, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread.github/workflows/kanban-closure-router.yml Outdated
…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>

@saadqbalsaadqbal 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.

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 recorded
  • advance-deploy-env.yml:251 — the rank() entry removed, "that instant has passed"
  • kanban-columns-check.py:22opt_either's call gone, only the historical note left
  • kanban-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-unreadableoverride-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.

@LukasWodka
LukasWodka merged commit 281ca5e into developAug 21, 2026
13 checks passed
@LukasWodka
LukasWodka deleted the fix/2324-override-status-vocabulary branch August 21, 2026 15:35
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

/fr-pass

Sign up for freeto 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

@LukasWodka@saadqbal