Skip to content

release-train: staging -> main - #363

Open
LukasWodka wants to merge 27 commits into
mainfrom
release-train/to-main
Open

release-train: staging -> main#363
LukasWodka wants to merge 27 commits into
mainfrom
release-train/to-main

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Automated promotion by the release train (RFC-0008 D14). Head is the train-managed release-train/to-main branch (a mirror of staging), so it never collides with a human PR. Merged only when the fr-gate is green.


Note

High Risk
Org-wide kanban routing and deploy-env behavior change how every closed issue and promoted PR is represented on the board; combined with fleet bugbot callers and narrowed App tokens, mis-scoping or routing bugs would surface as silent card drift or red CI across many repos.

Overview
This promotion bundles a large tracebloc/.github rollout: kanban behavior, new org guards, tighter GitHub App token scopes, and inventory/docs alignment.

Kanban (backend#2722) — Completed issues now route to Done in kanban-closure-router instead of mirroring a closing PR’s deploy column. The matching cross-repo “advance closing issues” loop is removed from advance-deploy-env, and kanban-reconcile stops backfilling issues into deploy states. App minting drops permission-issues where issue reads are gone.

Gates & callers — Adds bugbot-gate-caller.yml (advice-only rollout; inventory flips bugbot-gate.yml to required fleet-wide) and conflict-gate.yml (30‑minute cron writes merge-conflict status on PR heads when pull_request workflows never run). fr-gate-caller and set-pr-status-caller gain edited so base-branch and title/body changes re-run checks; set-pr-status only updates cards on open PRs and uses Code review for non-drafts. bugbot-gate.py splits unclaimed vs pending heads, exits 0 on unclaimed after wait but still fails if open findings exist from an earlier review. closing-ref-gate.py accepts canon-derived body refs like Part of owner/repo#N, not only Closes.

Reliability & least privilegekanban-archive logs credential visibility, adds a post-archive “board is clean” assertion (fail-closed on null Status / totalCount mismatch). fr-gate, set-pr-status, kanban-reconcile, and standards-sync narrow App installation permissions; standards sync uses SYNC_PR_AUTHOR_TOKEN for PR creation so Bugbot can review sync PRs.

Inventory & standardsrepo-inventory.yml reflects design-system-v2 on the release train (protection still exempt until armed), removes bugbot_gate_rollout_pending, and refreshes several exemption narratives. org-standards.md / CLAUDE.md point branch models at the inventory, document the three repos missing work-type:bug, and expand PR title/link and cleanup guidance. Makefile wires selftests/mutations for conflict-gate, mutation-baseline, and standards-sync.

Reviewed by Cursor Bugbot for commit 50c8f65. Bugbot is set up for automated code reviews on this repo. Configure here.

LukasWodkaand others added 25 commits August 25, 2026 20:33
…336)
Step 2 of the reusable's three-step arming order, rolled from the
claude-skills pilot (claude-skills#36) to the rest of the fleet.
No branch protection is touched and `bugbot / review` is not required:
Bugbot does not review Dependabot PRs, so a required context would park
every one of them red with no route to green. Reported, not required.
repo-inventory.yml is untouched -- caller first, entry after; the flip to
`required` is the follow-up.
Refs tracebloc/backend#2284
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…2284) (#337)
Ten bugbot-gate callers are now live (claude-skills from the pilot, plus
nine merged 18:33 today). Their rows still read exempt, which is the
stale-exemption finding at caller-drift.py:2309 -- the accepted cost of
land-the-caller-first, and this closes it.
Derived, not listed: the flip script re-reads repo-inventory.yml from disk
and confirms each caller by matching the resolved `uses:` value on that
repo own audit branch, so a required row cannot be written ahead of the
caller it claims. Ten repos whose PRs have not merged stay exempt.
The anchor is RETAINED at ten citations and its text corrected. It has now
been wrong twice -- first asserting ZERO callers org-wide, then naming only
the pilot -- so it now says outright that its own list is not to be trusted
and points at the anchored grep instead. It also carries its own delete
condition for when that grep returns zero.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`edited` is the only pull_request event GitHub fires on a base-branch change.
Without it a retargeted PR can never re-run fr-gate: synchronize needs a push,
and a retarget has nothing to push.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…appened (#342)
* fix(2516,2517): two reasons that describe something other than what happened
Both deferred from the .github#334 staging hop under the severity policy,
both verified against source before fixing.
2516 -- claude-skills fr-gate. The reason framed the caller absence as the
audit DISCOVERING a missing caller, and ended "if the caller was removed by
mistake, restore it and flip this back". It was not a mistake:
claude-skills#38 deleted fr-gate-caller.yml on purpose (+0/-15) as the
remainder of backend#2347, because this repo does not ride the train. The
remedy does not run both ways and the reason no longer says it does.
2517 -- the rfcs prod cell claimed reason-citations.py "can at least check
that a cited issue is still open" while naming backend#2501 only in a YAML
comment, which that script never reads. The claim is now the opposite and
says why moving the citation into a scanned string is NOT the fix: the only
reason-bearing field there is an alias to a shared anchor.
The citation guard then caught this change: the new 2516 text cites
backend#2347, which is CLOSED. That is the guard working, so #2347 is
EXEMPTed with what it is doing there rather than reworded out to silence it.
Also folded in, because repo-inventory.yml is a GUARDED file and two PRs
cannot both hold a green audit on their own SHA: the five bugbot-gate rows
whose callers merged today. The anchor stops enumerating repos -- that list
went stale twice and is now derived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(2284): the four callers that merged at 07:46 get their rows too
Folded onto this PR rather than opened separately: repo-inventory.yml is a
GUARDED file needing a green org audit on its own SHA, and callers kept
landing while this sat unmerged. Two PRs racing for that green is a
deadlock, not a queue.
averaging-service, data-ingestors, tracebloc-engine and tracebloc-py-package
all merged at 07:46. 19 of 20 rows are now `required`; design-system-v2 is
the only one left, and its caller PR is held by a pre-existing Code Connect
failure on that repo (backend#2523), not by anything in the rollout.
This PR had already passed `audit` -- before those four merged. That green
was stale the moment they landed, which is the re-run trap the release-train
README documents, one layer out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: a PR must not cite the issue it closes in a SCANNED string (Bugbot High)
The rewritten claude-skills fr-gate reason cited backend#2516 inside an
`exempt:` string -- and this PR closes backend#2516. reason-citations.py
would then see a citation to a CLOSED issue on every subsequent PR and fail
the required selftests job fleet-wide. It could not be pre-EXEMPTed either,
because the issue is open until this merges, and an EXEMPT row for a live
issue is itself a stale exemption.
A self-inflicted trap and a good catch: the citation now names the hop
(.github#334) rather than the ticket the merge closes.
Also -- the folded scalar lost its indentation AGAIN, from my own edit that
removed the stale enumeration. Three continuation lines sat two spaces
deeper than the block base, so they stopped folding and injected literal
newlines into every repo aliasing the reason. Second time today. Verified
this time by loading the file and reading the rendered value rather than by
reading the diff, which looked fine both times.
And the splice that removed the enumeration had duplicated the
claude-skills#36 parenthetical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(inventory): the blocked-gate reason named two live PRs; neither is live
client-runtime#192 was closed unmerged at 08:26 today, which the citation
guard caught. Reading the sentence caught the other half: client#490 had
already merged. "two live PRs go red the moment it is on" described the
fleet at writing time and nothing since.
Restated in the past tense, and the citation kept -- the reason now records
that #192 was closed rather than leaning on it landing, so the deadness is
the content. EXEMPT carries that rationale and says when to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ase they are (#343)
Bugbot Medium, deferred from the .github#334 staging hop. The block was
created 2026-08-25 by copying ON-TRAIN design-system, so all three
promotion-shaped rows arrived `required` while every other off-train repo
exempts version-bump and fr-gate. The rows were UNEXPLAINED rather than
wrong -- and measured 2026-08-26, the three are not the same case at all:
version-bump-gate branches: [develop] -- ACTIVE on every PR here
fr-gate branches: [staging, main, master] -- NONE exist, dormant
fr-pass-comment issue_comment, no branch filter -- CAN fire, on a repo
with no `FR on staging` card to advance
All three stay `required` because all three callers EXIST, and an exempt row
whose caller is present is the stale-exemption finding at
caller-drift.py:2309 -- flipping them would trade an unexplained row for a
red audit. Exempting means deleting the callers first, which is an
onboarding decision and stays open on backend#2512.
Comment-only: no cell value changes.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…suming it (#340)
* fix(2441): the mutation harnesses verify their baseline instead of assuming it
All seven `scripts/tests/*-mutations.py` runners overwrite a tracked file, run
the suite, and restore it in a `finally`. That covers a crash. It does not cover
SIGKILL, a runner timeout, or a second harness racing the first in the same
worktree -- and the mutation left on disk becomes the NEXT run's `pristine`.
Every mutation is then measured against a premise nobody typed, the restore
writes the corruption back, and the run reports `0 uncaught`, which is
byte-identical to real coverage.
Fail-open in the one direction that matters, in the tier whose whole job is
proving guards catch bugs. It has already destroyed a tracked file here
(`scripts/pipefail-early-close.awk`).
`scripts/tests/mutation_baseline.py` refuses to start unless every file the
runner is about to mutate matches its committed content at HEAD, and every
way of not being able to tell -- unreadable file, git that will not run, no
HEAD, untracked path, any git status that is neither "matches" nor "differs"
-- refuses too. It does not restore: those bytes might be somebody's work.
Only the writing path is guarded. `--dry` writes nothing and is what
`make check` runs on every push, where refusing on an uncommitted edit would
block the pre-push tier for whoever is editing the target.
Closestracebloc/backend#2441
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(2441): derive the runner count instead of writing it down (saqlainsyed007)
The module docstring and the selftest comment said "seven" *-mutations.py
runners in three places -- and this PR adds the eighth, so all three were
wrong the moment it landed.
Off-message for a change whose thesis is verify the baseline rather than
assume it, and it is the same stale-hardcoded-count trap Makefile:277
already records as having "gone stale twice over".
Bumping seven to eight would have been wrong for the same reason it was
wrong at seven. The counts are gone instead: the guard already derives the
roster by globbing *-mutations.py and fails closed on an empty glob, so
there was never a reason for prose to hold a second copy of the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2441): the runner must not ask the mutated guard whether it is mutated
Bugbot Medium on #340, and it is this change failing in its own terms.
The runner imported `mutation_baseline` from disk and then handed that
same in-memory copy `mutation_baseline.py` to certify. A run killed
mid-mutation leaves a fail-open `guard()` on disk, so: the import binds
the broken one, it certifies its own corruption as clean, `pristine`
captures the MUTATION, and the `finally` writes it back for good. Every
later harness then imports a guard that no longer guards -- backend#2441
fail-open, one level up, inside the fix for it.
It now loads the guard from HEAD (`git show HEAD:<rel>`). Not a second
copy of the rule -- that would be the thing that drifts -- but the SAME
function read from the one version a mid-run kill cannot have touched.
Fails closed on no git, no HEAD, no such path, or a file that will not
compile, because the fallback IS the defect.
Measured: with a fail-open guard planted in the tree, the runner refuses
with exit 2 and leaves the corrupted bytes on disk rather than adopting
them. Before this it would have written them back as the baseline.
The selftest`s per-runner check matched `mutation_baseline.guard(`, so it
would have failed the fix; it now matches `.guard(` -- the question is
whether a guard runs before the first write, not how it was bound. Two
assertions added for the regression itself, both mutation-proven.
Verified: make lint clean, selftests 28 passed 0 failed, 12 mutations
0 stale 0 uncaught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…s worked (#339)
* fix(2526): the archive asserts the board is clean, not that its writes worked
Every guard in this workflow answered "did my writes succeed?" and every one of
them could answer yes while the board was untouched. That is what happened.
MEASURED, 2026-08-26. The 05:14 scheduled run logged:
Un-archived terminal items: 63
=== Archived: 63 ok, 0 failed ===
exit 0, green, as it had been for weeks. At 06:24 a human PAT read the same board
and found 668 un-archived terminal cards -- 475 Prod, 153 Done, 40 Cancelled. A
manual dispatch minutes later reported "Un-archived terminal items: 0". Same
board, same query shape, two credentials, two answers.
The read logic is not the bug: it paginates correctly and filters correctly. The
bug is that "I archived everything I saw" was allowed to stand in for "the board
is clean", and nothing measured the difference. This file's own header had already
named the failure mode -- "the board could degrade for weeks behind an unbroken
green history ... slowly enough to look like normal growth" -- and then guarded
only against a FAILED archive, not against an archive that sees nothing.
TWO CHANGES.
1. Say what the credential actually saw. Items read, un-archived, and -- the one
that matters -- how many returned a NULL Status. A null means the item came
back but its field value was unreadable, so it is silently dropped by the
filter and reported nowhere. That count is the measurement that separates "the
board is clean" from "this credential cannot see the board", and nothing has
ever printed it. A non-zero count raises a warning rather than passing quietly.
2. Re-read AFTER archiving and fail on anything terminal still live.
Deliberately a SECOND read rather than arithmetic over the first. The defect
being caught IS that the first read is incomplete, so comparing it against
itself would agree and prove nothing -- the test-a-list-against-itself trap
this org's rules name directly.
It fails closed: an unreadable re-read exits 1 rather than reporting a clean
board, because unverified is not verified-clean.
WHAT THIS DOES NOT DO. It does not fix the cause. The `tracebloc-release-train`
App is installed on ALL repos with `organization_projects: write`, so narrow repo
visibility is refuted; it can also read and write the project, since it resolved
the project id and archived 63 items successfully. The remaining candidate is the
field read, and the diagnostic above is what will name it on the next scheduled
run. Arming the assertion first is deliberate: it means the next silent
degradation cannot be silent.
Expect this to go RED on its next run, with 668 items still terminal. That is the
guard working, not a regression -- and the board it describes was already in that
state, unreported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2526): the assertion was unreachable in the case it exists to catch
Two Bugbot Highs on #339, both real.
UNREACHABLE. The board-is-clean re-read sat at the bottom of "Archive
them", below `if [ "$n" = "0" ]; then ... exit 0; fi`. When the first read
finds nothing terminal -- which is exactly what a credential that cannot
see the cards produces, and exactly the silent success this PR is about --
the step exited before asserting anything and the job went green having
checked nothing. A guard reachable only when the bug is absent is not a
guard. It is now its own step, so it runs whatever the archive did.
SAME CREDENTIAL, SAME BLIND SPOT. The old comment claimed a second read
avoids comparing the first read against itself. True of the item list;
false of what made the incident invisible. On 2026-08-26 the items WERE
returned -- their Status came back null for this credential, so they
matched no terminal filter and `left` stayed 0. A second read with the
same token reproduces that and agrees with itself. So a null Status on an
un-archived item is now a refusal rather than the ::warning:: it was, and
a re-read that saw fewer items than the first read is a refusal too.
Skipped in dry run: a dry run archives nothing, so the board is dirty by
construction and this would be permanently red.
Verified: make lint, make selftests (95 passed), make shellcheck clean,
actionlint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…345)
* fix(2555): the sync PR title stops naming an epic it must not close
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2555): the body check covers every closing-keyword family, not just Closes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…hor go (#346)
`design-system-v2` took a `bugbot-gate-caller.yml` and its inventory row still
said `exempt`, which is the last of the backend#2284 rollout drift. Flipping it
takes the anchor's citation count to ZERO.
So the anchor is deleted, which is what it asks for on its own face:
DELETE THIS DEFINITION when that grep returns zero: an anchor left defined
and uncited is dead prose, which this file treats as a defect rather than as
harmless.
grep -nE '^\s*exempt: \*bugbot_gate_rollout_pending\s*$' repo-inventory.yml
-> no matches
That is the rollout finishing, not a shortcut: every active repo now carries the
caller, so there is no repo left for the exemption to describe. The anchor's text
had already been corrected twice as the fleet moved (backend#2507, .github#335),
and it says outright that writing a count there was the mistake -- leaving a third
stale version behind would repeat it.
WHY THIS KEPT RECURRING, recorded because the pattern cost six hops. `.github`'s
`audit` is a REQUIRED check that measures the LIVE state of every repo, while
`.github`'s own diff is the inventory describing that state. So its mirror can be
correct at the moment it is cut and wrong by the time the audit runs, for reasons
that have nothing to do with its contents. Today: a fleet-wide hop moved 16 repos,
nine bugbot-gate rows went stale, .github#337 fixed them on develop, the mirror
predated that fix, a scoped recut carried it -- and then a tenth repo appeared.
Each fix was right and each was overtaken.
Nothing here changes that shape. It is worth its own ticket; this commit is the
nine plus the tenth plus the dead anchor.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ount (#347)
Bugbot HIGH on .github#341, and correct. The post-archive assertion I added in
#339 treats a fully paginated `nodes` list as the whole board, and it never asks
the server how many items the connection actually holds.
Two ways that passes while the board is dirty:
* `nodes|length` renders a MISSING or NULL path as 0. A query that resolved to
nothing at all counts as "no terminal items left" and the job reports clean.
* Items omitted from `nodes` while `totalCount` still counts them are invisible
to every check in the step. Both the first read and the re-read are this same
credential's, so they agree on the same subset -- or on zero -- and agree
confidently.
That second one is the exact defect #339 was written to close, one layer further
in. The file even said so out loud: "Both reads are this credential's, so this
cannot see what neither can." I wrote that sentence, and then let the assertion
rest on those two reads anyway. Comparing a read against another read by the same
blind credential is the test-a-list-against-itself trap, and #339's own commit
message claimed to have avoided it.
`totalCount` is the fix because it is the ONLY number in this job that does not
come from this credential's view of `nodes`. Three guards now:
1. the connection must resolve to an array -- an unreadable read fails rather
than counting as zero;
2. `totalCount` must be present and numeric, or completeness is unestablished;
3. paginated count must EQUAL `totalCount`, or items are being omitted and the
run cannot claim a clean board.
All three fail closed, which is the same rule the rest of the step follows.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Managed sync from tracebloc/.github/org-standards.md.
Co-authored-by: tracebloc-release-train[bot] <309815517+tracebloc-release-train[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…348)
* fix(2590): the sync opens its PRs as a human, so Bugbot reviews them
`standards-sync.py --create-prs` authenticated `gh pr create` with the
tracebloc-release-train App token, making the PR author
`tracebloc-release-train[bot]` (type: Bot). Cursor Bugbot keys its review on the
author's Cursor seat and a Bot has none, so Bugbot reviewed NONE of the 14 open
sync PRs -- zero `Cursor Bugbot` check runs, measured 2026-08-26 -- and
`bugbot / review` failed closed on every one. An explicit `bugbot run` is
attributed to the author too, so it answers "Bugbot is not enabled for your user
on this team" and cannot rescue the PR.
`gh` now takes an explicit identity and PR CREATION ALONE uses it, from
SYNC_PR_AUTHOR_TOKEN. The fleet reads and the branch push keep the App token:
`owner:`-scoped, short-lived, not tied to one account, which is backend#2036.
An empty PAT is a hard per-repo refusal, never a quiet downgrade -- the
downgrade IS the bug, and it looks identical to success.
Reviewer and assignee become SYNC_REVIEWER rather than GITHUB_ACTOR. The
dispatcher is now the author, and GitHub refuses an approving review from a PR's
own author, so keeping GITHUB_ACTOR would deadlock every sync PR. The 4 open PRs
already requesting LukasWodka were reassigned by hand.
Mutation-proved: 5 mutations, each caught by the assertion named for it. Writing
them surfaced two harness defects worth keeping out of the sibling runners -- a
mutation that applies but does not COMPILE reads exactly like good coverage, and
a suite that CRASHES on a mutation is red without a verdict (rule 10). The new
runner reports MALFORMED and CRASHED separately, and the empty-PAT case scripts
the whole happy path so the assertion is what reddens.
Closestracebloc/backend#2590
* fix(2590): the invariant is asked of the token, and the guard runs before the writes
@saqlainsyed007's review on #348. Both blockers, both should-fixes, and the
security point as far as code can take it.
F4 (BLOCKER) -- reviewer != author rested on two literals that agreed with each
other and with nothing else. `SYNC_REVIEWER = "saqlainsyed007"` here, and the
selftest pinning the other side as `!= "LukasWodka"`. Re-provision
SYNC_PR_AUTHOR_TOKEN to saqlainsyed007's PAT and author == reviewer, every
`--add-reviewer` 422s, the backend#2590 deadlock returns -- and neither literal
moved, so the suite stays GREEN through it.
A credential's owner is a fact about the credential, so `author_login()` asks
GitHub, as the PAT rather than as the ambient App identity, and `main()` refuses
when it equals SYNC_REVIEWER. An unresolvable token is its own refusal rather
than a guess.
F3 (should-fix, and the one with teeth) -- the PAT-empty guard lived inside
`_ensure_pr`, which runs AFTER `remediate()` has created the branch and pushed
CLAUDE.md. A missing token therefore produced a fleet-wide HALF ROLLOUT:
branches pushed to every drifted repo, PRs opened nowhere. Validation moved into
`main()`, before the first repo is touched. The in-function check stays as
belt-and-braces for a caller that skipped the gate, and says so.
F1 + F2 -- `_ensure_pr` returned as soon as an open PR tracked the branch, so a
PR whose `--add-reviewer` had failed once was never repaired on any later run;
and that failure was a `::warning::`. Together: a reviewer-less PR, which branch
protection makes unmergeable, shipping on a green run for ever. The role
assignment now runs on BOTH paths, and the reviewer half is FATAL. The assignee
half stays cosmetic on purpose -- it blocks no merge, and failing the run over it
would be the opposite error.
F5 -- the workflow's TOKEN header still said the App satisfied both needs, which
the step comment 90 lines below already contradicted. Reconciled, including the
paragraph arguing that "a second fleet-wide credential is the thing backend#2036
exists to stop": that argument was right about the risk and is overtaken by a
harder constraint, and saying so is better than deleting it.
SECURITY -- the in-code rationale claimed the PAT "needs `repo` scope; contents:
write alone cannot open a PR". That conflates two claims. The true one is that
the APP's contents:write cannot open a PR BUGBOT WILL REVIEW -- an authorship
problem, not a scope one. A fine-grained PAT with `pull_requests: write` +
`contents: read` opens PRs perfectly well and is dramatically tighter. Corrected
in place; the re-provisioning itself is a decision, not a code change, so it is
recorded rather than asserted as done.
TWO DEFECTS I FOUND IN MY OWN TEST EDITS, worth recording because both are the
shape this repo keeps paying for:
* The new checks were appended AFTER the summary line and `sys.exit(1)`. They
ran only when everything else passed, were never counted, and a failure
among them could not affect the exit code -- a test block that inspects
nothing. Moved above the summary; the count went 36 -> 42, which is how it
was caught.
* Stubs scripted `(0, "[]", "")` for "no open PR". The real call carries
`--jq '.[0].number // empty'`, so no-PR is an EMPTY string and `"[]"` is
truthy. Now that the existing-PR path also makes two edit calls, a `"[]"`
stub consumes the same number of entries down the OPPOSITE branch -- passing
while testing the case it is named against. Three stubs corrected.
Verified: selftest 42 checks / 0 failed, `py_compile` clean, ruff clean,
actionlint clean on the workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2590): the identity gate is extracted so it can be exercised
The mutation harness reported "SYNC_REVIEWER becomes the account that authors
the PRs" as UNCAUGHT once the literal-vs-literal check was retired. A guard
nothing can exercise is the shape this repo keeps removing, so the gate is a
function now and each refusal is pinned individually.
And the two mutations are re-aimed at what can actually break. Flipping the
SYNC_REVIEWER literal is SURVIVABLE now -- the runtime gate refuses the run --
so the mutation that matters is dropping the comparison, not changing one of its
operands. The reviewer-reverts marker follows the reviewer call into
_assign_roles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2590): the selftest could not go red below line 486
`failed = [...]` was computed a hundred lines above the last checks, so anything
appended below it PRINTED its FAIL and did not count -- the summary read `0
failed` while two checks had failed, and the script exited 0.
The mutation harness is what surfaced it: a gate mutation came back UNCAUGHT
because the suite it was measured against was structurally incapable of failing
there. A test file that cannot go red is the same class as a guard that cannot
fire, one level up.
Also re-aimed the unresolvable-token mutation. Skipping the `is None` branch
crashes on `login.lower()` -- red without a verdict, which tells the harness
nothing. Making `author_login` return a login it could not resolve is the same
defect with a readable outcome.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2590): a PAT that resolves but cannot create stops the fleet (Bugbot on #348)
`check_author_identity` proves the token EXISTS, RESOLVES and is not the
reviewer. Bugbot's point is that none of that proves it can open a PR -- the
wrong fine-grained permissions, or a token never SSO-authorized for the org,
passes every one of those checks and then fails at `pr create`, by which point
`remediate` has pushed a branch and a commit to every drifted repo. That is the
half-rollout the gate was added to prevent, arriving through the one failure
mode the gate cannot see.
AND NOTHING READ-ONLY CAN CLOSE IT. "Can this token open a PR in this org" has
exactly one proof, which is opening one; a `GET /user` or a repo-permissions
probe narrows the odds and still cannot answer it. So the guarantee is BOUNDED
rather than claimed: `pr create` failing now raises `AuthorUnusable`, `main()`
aborts the remaining targets, and at most ONE repo is left with a branch and no
PR instead of nineteen.
A DISTINCT TYPE, not a string the loop matches on prose. The loop has to tell
"this repo failed" from "this credential fails everywhere", and those are
different facts with different correct responses -- continue versus stop.
The report says it happened. A run that stopped early and did not say so reads
as a complete sweep of a smaller fleet, which is the same shape as every other
silent-success defect this PR has been about.
Verified: selftest 49 checks / 0 failed, py_compile and ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2590): an open bot-authored sync PR is refused, not reported ensured (Bugbot on #348)
The existing-PR path repaired roles and returned None, so every sync PR
already open -- all of them opened as tracebloc-release-train[bot] -- would
have been reported ensured while staying exactly as unreviewable as before.
A PR's author cannot be reassigned, so the only honest outcome is a per-repo
error naming the remedy. is_bot is read from GitHub rather than pattern-matched
off the login; an unparseable row fails closed rather than reading as human.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(2590): a refused PAT disarms the writes instead of skipping the audit (Bugbot on #348)
Moving the credential gate into main() stopped the half-rollout, but it was
written as die() -- which also skipped the read-only fleet audit. standards-sync.yml
argues against exactly that twenty lines above the secret: aborting before the
audit turns "PRs could not be opened" into "fleet state unknown", strictly less
information. The gate exists to stop writes, and the audit is a read.
A refusal now switches remediation off: every target is still classified and
reported, each drifted row reads NOT REMEDIATED, the report carries a
REMEDIATION DISABLED block naming the cause, no branch is pushed, and the run
exits 2 rather than the --create-prs 0 (which would claim every drifted repo
has a PR open). The workflow comment is updated so it no longer describes a
placement the code has moved past.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ing (#349)
backend#2157, ONE OF FOUR -- this does not close it.
`set-pr-status.yml`s first mint passed `owner:` with no `permission-*`
inputs, so it minted the App ENTIRE permission set -- contents:write,
issues:write, administration:read and the rest -- across every repo the
installation covers, on every PR event in the fleet. Largest exposure of
the four purely because of how often it runs.
SCOPES DERIVED FROM THE CALLS, not copied from a template:
organization.projectV2 { fields } -> projects, read
repository.pullRequest.projectItems -> pull-requests, read
updateProjectV2ItemFieldValue -> projects, WRITE
No `issues` scope: this job is PR-only. The SECOND mint in this same file
already carried `permission-pull-requests: read` + `permission-issues:
read` -- the closing-ref job reads the linked issue. Worth naming because
the exemption is per FILE, so `set-pr-status.yml` read as wholly unscoped
while half of it was already done.
Exemption removed. The guard now reports 3 unscoped / 3 exempted / 0
findings, down from 4 and 4.
A PASSING MINT IS NOT THE PROOF, and the comment says so: release-train
add-to-kanban.yml records that "a mint can succeed with a scope the board
write then needs and lacks". The evidence is a card that actually moves,
which is why this lands alone rather than with the other three.
Verified: make lint clean, selftests 28 passed, actionlint clean.
Part of tracebloc/backend#2157
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…353)
backend#2157, three of four -- this does not close it.
NARROWED, NOT MADE READ-ONLY. `kanban-reconcile.yml` genuinely writes:
three ProjectV2 mutations, and it CREATES labels (POST repos/{o}/{r}/labels)
and applies them (POST .../issues/{n}/labels) for `keep-open` and
`sibling-merge`. Claiming it read-only would break the Monday run.
THE WIN IS `contents`. It minted contents:WRITE across every repo the
installation covers -- the capability that can push code anywhere -- for a
job whose only content access is READING .github/workflows/add-to-kanban.yml
and a couple of branch refs. That drops to read; administration:read,
actions:read and checks:read go entirely.
DERIVED FROM THE CALLS:
contents/... , branches/{main,master} -> contents: read
label create + apply -> issues: WRITE
the same labels on PRs -> pull-requests: WRITE
addProjectV2ItemById / updateProjectV2ItemFieldValue /
archiveProjectV2Item -> organization-projects: WRITE
`pull-requests: write` is retained because the labels land on PRs as well as
issues and the endpoint is shared. Guessing it short would fail on a path
that only runs weekly, so it stays until measured rather than assumed away.
THIS ONE IS PROVABLE WITHOUT A HOP, which is why it goes before
standards-sync: .github default branch is `develop`, crons run from the
default branch, and this workflow has a `dry-run` dispatch input. So after
merge, `gh workflow run kanban-reconcile.yml -f dry-run=true` exercises the
real mint and every read with no mutation -- the only one of the four where
"a card that actually moves" is obtainable the same day.
Verified: make lint clean, selftests 28 passed, actionlint clean,
mint-scope 3 unscoped / 3 exempted / 0 findings.
Part of tracebloc/backend#2157
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…354)
Four PRs in one day failed `closing-ref` for the same reason -- .github#349,
#350, #352, #353 -- and all four were fixed by retitling. That is a rule the
canon is missing, not four mistakes: a finding that recurs across PRs becomes a
rule rather than a fifth hand-fix.
THE COLLISION. The canon says commit subjects are `type(scope): summary`,
`referencing the ticket (backend#1234)`. Read quickly, `(scope)` looks like the
ticket slot, so sessions write `sec(2157): ...`. `closing-ref` then reads a bare
number in the title as a PROMISE to close that ticket and fails unless the body
carries a closing keyword -- which for partial work would be WRONG to add. Every
one of the four was partial work correctly saying `Part of tracebloc/backend#N`,
and every one went red for saying so.
The canon's own example is already the qualified form (`backend#1234`), not a
bare number; the gap is that it never says the number does not belong in the
subject, and that a bare one has a specific machine meaning. Both are now
stated.
WHY NOT LOOSEN THE GATE INSTEAD. The gate is right and its failure message is
excellent -- a title reference is inert, and it cannot tell which repo owns a
bare number, so it would accept a link to any repo at that number. The defect is
the convention pointing one way while the check reads the other.
Verified: `classify` reports IN_SYNC (canon and CLAUDE.md agree), standards-sync
selftest 31 checks / 0 failed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…hing (#352)
backend#2157, two of four -- this does not close it.
`fr-gate.yml` performs ZERO mutations. No GraphQL mutation, no `gh pr/issue`
write, no REST POST/PATCH, no git push. It reads and reports its own
conclusion. It was nevertheless minting the App entire permission set --
contents:write, issues:write, organization_projects:write,
administration:read -- across every repo the installation covers.
A pure reader holding org-wide write is the sharpest mismatch of the four.
SCOPES TAKEN FROM THE THREE CALLS THE FILE OWN COMMENT ALREADY NAMES, which
is why this could be checked rather than guessed:
commits/{sha}/pulls -> contents: read + pull-requests: read
compare/{base}...{head} -> contents: read
ProjectV2 graphql -> organization-projects: read
`actions/checkout` is not on this token -- it takes the job default
GITHUB_TOKEN -- so the checkout needs nothing from here either.
IF THE SCOPE IS SHORT the gate fails closed and blocks promotions: loud,
immediate, and recoverable with the `skip-fr-gate` label. That is the
failure direction this gate already chose for itself -- "blocking wrongly
is far better than passing wrongly".
NO SIGNAL UNTIL THE HOP, and the body says so: callers pin
`fr-gate.yml@main`, so every promotion keeps using main unscoped copy until
develop -> staging -> main lands. A green run here proves nothing.
Verified: make lint clean, selftests 28 passed, actionlint clean,
mint-scope 3 unscoped / 3 exempted / 0 findings.
Part of tracebloc/backend#2157
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…erent reason (#350)
* docs(2598): the bug-to-ready exception is two repos, and for a different reason
@saqlainsyed007's blocking finding on the sync PRs (client#848). He is right,
and chasing the mechanism moved the reason as well as the count.
MEASURED. All three named repos now have a caller -- `.github`'s landed under
backend#2396, and `release-train`/`rfcs` both carry a real
`customer-priority-bump.yml` calling the reusable. So caller presence is no
longer the differentiator, and `.github` is fully wired: it has the caller AND
all four `work-type:*` labels. The list was wrong by one, exactly as the review
said.
What remains is narrower and invisible: `release-train` and `rfcs` have no
`work-type:*` labels AT ALL. Neither has its own `.github/ISSUE_TEMPLATE`, so
both inherit the org Bug template, which asks for `work-type:bug` -- and GitHub
SILENTLY DROPS a template label the target repo lacks. Nothing is added,
`bug-to-ready` never sees its trigger, and the card sits in `Backlog`. A caller
without the label is wired in exactly the way that does nothing.
THE ANCHOR THE OLD TEXT CITED IS GONE. `customer_priority_bump_caller_missing`
no longer appears anywhere in `repo-inventory.yml` -- it was removed when the
exemptions were retired -- so the reusable's header sent a reader to a key that
returns nothing. That is worse than a stale count: it is a doc teaching a lookup
that cannot answer. Corrected in place, with the new reason stated and
backend#2598 named as where it gets closed.
THREE COPIES, because the sync only reaches one of them. `org-standards.md` and
its 19 synced `CLAUDE.md` blocks travel together, but a workflow COMMENT does
not -- so the reusable's header and the caller's header were both hand-corrected
here. The review's second point ("check the sync actually reaches every copy")
is the reason I went looking; it does not, and two of the three copies were only
reachable by hand.
ON NOT REPLACING A DRIFTED LIST WITH A FRESH ONE, which is the review's real
argument: the sentence now states the CONDITION rather than a roster -- the
label has to exist for the trigger to match -- so a reader can check it in one
API call instead of trusting prose. It still names two repos, and that is
deliberate rather than settled: backend#2598 creates the labels, at which point
the exception has no members and the sentence is DELETED rather than kept
accurate by hand. Emptying the list is the fix; re-writing it is not.
Verified: `classify` reports IN_SYNC (canon and CLAUDE.md agree), standards-sync
selftest 31 checks / 0 failed, both workflows parse, actionlint clean,
reason-citations 0 findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(kanban): three repos, derived from the inventory rather than from the prose
@aptracebloc's review on #350. He is right on every point, and the one that
matters is the one I would have kept getting wrong.
THE COUNT IS THREE. `design-system-v2` is in the identical broken state to
`release-train` and `rfcs`: no `work-type:*` labels at all, no own
`.github/ISSUE_TEMPLATE`, and `customer-priority-bump.yml: required` in
repo-inventory.yml (added 2026-08-25 via .github#332, after the prose this PR was
written against).
WHY I MISSED IT, WHICH IS THE ACTUAL DEFECT. I checked the three repos the OLD
SENTENCE NAMED and found one of them wired, so I wrote "two". I never asked the
inventory which repos declare the caller. That is CLAUDE.md rule 6 exactly --
derive the input domain from the producer's declared surface and test all of it,
because a vocabulary gap is invisible to any amount of checking inside the wrong
domain. In a PR whose whole argument is "restating the authority is the defect",
I restated the authority.
Derived properly this time: `repo-inventory.yml` declares
`customer-priority-bump.yml: required` for TWENTY repos and exempts none; all 20
were measured, and exactly three have an empty label set. The arithmetic follows
-- 20 declared minus 3 unlabelled is 17 covered. The previous "17" was right only
by coincidence, being 19 minus two.
Fixed in all four places the omission appeared, as asked: `org-standards.md`,
`CLAUDE.md`, and both workflow headers. The reusable's header also now records
that the list is derived and why an earlier version named two, so the next reader
sees the trap rather than just the corrected number.
MINOR, ALSO HIS: "grepping for it returns nothing" was too broad. The
`customer_priority_bump_caller_missing` key is gone from `repo-inventory.yml`
(true, and the point), but the STRING still appears in
`scripts/reason-citations.py` and in the comment itself -- I had grepped it up
earlier in this same session and still wrote the wider claim. Scoped to the
inventory.
backend#2598 widened to three repos in the same pass, since it was scoped to two
and would have left `design-system-v2` broken after landing.
Verified: `classify` reports IN_SYNC, standards-sync selftest 31 checks / 0
failed, both workflows parse, actionlint clean, reason-citations 0 findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
backend#2157, LAST OF FOUR. With #349, #352 and #353 this empties EXEMPT.
Scoping this to the UNION would have kept fleet-wide contents:WRITE on every
scheduled report -- which is the mismatch, not the fix, since `report` mode
only reads. So the contents scope is conditional on the mode:
permission-contents: ${{ (dispatch && create-prs == true) && write || read }}
On a schedule run `inputs` is empty, the condition is false, and the same job
gets READ.
DERIVED FROM THE CALLS THIS TOKEN MAKES, checked one at a time:
git/ref/heads/develop, repos/{r}, contents/CLAUDE.md?ref= -> contents: read
PUT contents/CLAUDE.md, POST git/refs (create-prs only) -> contents: WRITE
gh pr list -> pull-requests: read
gh issue comment 1602 (the drift report) -> issues: WRITE
NOT pull-requests:write: `pr create` and both `pr edit` calls run on
SYNC_PR_AUTHOR_TOKEN, not on this token (backend#2590). The App only LISTS.
TWO CORRECTIONS TO WHAT I SAID EARLIER ON THE TICKET, both from reading the
file rather than the summary:
* The minting job is gated on `schedule || workflow_dispatch`, so it never
minted on push/pull_request/issues. The exposure was real but narrower
than "nearly every run".
* `== true` rather than a bare truthiness test, matching the CREATE_PRS line
below it. Equivalent for a `type: boolean` input -- but if it ever became
a string, `false` is TRUTHY and this would mint write on every scheduled
run while still looking conditional.
Verified: make lint clean, selftests 29 passed, actionlint clean, mint-scope
3 unscoped / 3 exempted / 0 findings on this branch (the other three are on
their own branches; together they reach 0 and 0).
Part of tracebloc/backend#2157 -- the LAST of four. Merge order is not
guaranteed, so this does not carry a closing keyword: if it landed first it
would close the ticket with three mints still unscoped. Close #2157 by hand
once #349, #352, #353 and this one are all in.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…356)
* fix(2284): the gate tolerates a review that never came, and says so
Two absences were one verdict, and they mean opposite things.
`PENDING` covered both "Bugbot is still running" and "Bugbot never showed
up", and the timeout failed both identically. A check that STARTED and never
finished is a review that BROKE -- worth blocking on. A check that never
appeared is Bugbot dropping the PR, which this repo cannot fix, retry, or
wait out.
Measured 2026-08-26 on human-authored PRs, well past p50 164s / max 635s:
six of nine never got a check at all -- .github#349 (57 min), #350 (55),
#352 (40), #353 (37), #354 (32), e2e-test-agent#273 (2h+) -- while #351,
opened BETWEEN two of them, was reviewed in three minutes. Not latency, not
the seat limit, not the author. backend#2114 closed COMPLETED saying "no
discriminator survives the data"; the drop is still live.
`bugbot run` cannot recover it: Cursor refuses on a seat limit and the App
will not be given one (decision, 2026-08-26).
So requiring this context while failing UNCLAIMED would block roughly two
thirds of PRs for the full wait and then fail them with no remedy -- the
gate would look broken while behaving exactly as written.
WHAT THIS IS NOT: it is not a pass. UNCLAIMED exits 0 so the context can be
required, and every other word says the head is UNREVIEWED -- its own banner,
its own summary line, and an explicit "read the diff yourself". The honest
report is "nothing looked at this", not "this is clean".
PENDING still blocks, and that asymmetry is the whole change.
Also corrected: the vocabulary comment said "Only PENDING is waitable",
which this makes false. `main` now READS `WAITABLE` rather than restating it.
79 selftest assertions (4 new through `main`, since the exit code is the
behaviour and `evaluate` alone cannot show it). Registry: 1 stale marker
fixed, 5 mutations added -- the collapse in both directions, the tolerance
leaking onto PENDING, the tolerance removed, and the banner reading as a
pass.
Part of tracebloc/backend#2284
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(2284): pin the UNREVIEWED banner, which no assertion covered
The mutation `the UNREVIEWED banner reads as a pass` came back UNCAUGHT.
Every other assertion in this suite checks a verdict or an exit code, and
the banner is neither -- it is the one line a human skimming the summary
actually reads, and the only thing standing between "exits 0" and "looks
clean".
Three cases: the UNCLAIMED headline says UNREVIEWED, does not contain the
word pass, and a genuine pass still does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: hoist the selftest imports ruff flagged
E402/E401 from the banner test added in the previous commit. `make lint` is
where I should have run it before pushing, not after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2284): the threshold is applied before the head is classified
Bugbot on #356, High: `evaluate` returned UNCLAIMED as soon as the head had
no Bugbot check, without applying the open-finding threshold to the threads it
had ALREADY loaded one block earlier. So the tolerance this PR adds for a
dropped review would launder a finding that had already come -- review head A,
get a High, push head B, Bugbot never claims B, and the gate reports
UNREVIEWED-but-not-blocked over an open High at exit 0.
The same hole was in PENDING, and fixing only the branch Bugbot named would
have left its twin. So `blocking` is now computed before `bugbot_check` is
consulted at all, and an absence of either kind with a blocking finding is a
FAIL that names the finding and does NOT wait -- the answer would not change.
Both absence verdicts now also state that the threshold was checked, so the
report says which question was answered.
The tolerance itself is intact and asserted: a Low on an unreviewed head is
still UNCLAIMED, exit 0, and so is a resolved High -- otherwise the remedy the
FAIL message names would not clear it.
Not a shipped bug: `required_conversation_resolution` is true on every train
branch, so the merge was blocked anyway. It was a gate naming the wrong reason,
which is the failure mode this file exists to prevent.
One renderer (`_finding_lines`) feeds both paths, because two would drift and
one of them would quietly stop saying OPEN (rule 9).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… (backend#2526) (#357)
* fix(kanban-archive): report every completeness finding, not the first (backend#2526)
The "Assert the board is clean" step had four checks, each exiting on the
spot. The most diagnostic one was ordered last, so the first to fire hid it.
Measured on the 2026-08-27 06:28 run: the step exited on a single null
Status, and `totalCount` -- the only number in the job that does not come
from this credential's own view of `nodes` -- was never compared. The run
therefore could not say whether the credential sees a SMALL board or is
being served an incomplete view of a LARGE one, which is the exact question
backend#2526 needs answered. That run read 92 items; a human PAT reads 1362.
One short-circuit cost a whole run's worth of diagnosis, and the next run
would have cost another.
All four now record into `fail` and the step exits once at the end, with the
server's own count checked FIRST and a step-summary table of the four
numbers. A clean board additionally prints the paginated-vs-totalCount
agreement, so a green run says what it verified instead of staying silent.
Mutation-proved on the extracted decision block:
all four bad -> exit 1, 4 findings (was 1)
all clean -> exit 0, 0 findings, prints agreement
only totalCount wrong -> exit 1, 1 finding (previously masked)
* fix(kanban-archive): repoint the ticket citations at backend#2623
The workflow cited backend#2526 (7x) and backend#2528 (2x) as the source of
its own guards, including inside the ::error:: text an operator reads when
one fires. Both resolve to unrelated merged PRs:
backend#2526 -> fix(fr-gate): re-run the gate when a PR changes its base
backend#2528 -> release-train: develop -> staging (a promotion PR)
So a guard that fired sent whoever read it to an fr-gate change. No ticket
for the archiver's blindness existed at all; backend#2623 is now that ticket
and carries the 92-of-1361 measurement plus the two refuted causes.
`design-system-v2` joined the release train on 2026-08-26T12:12:21Z
(release-train/repos.yml commit 8330f408, onboarded under backend#2547), and
`repo-inventory.yml` still recorded `release_train: false`. caller-drift.py
cross-checks that flag against repos.yml, so the disagreement was a
repo-conformance finding -> a red `audit` -> a red REQUIRED `gate` context ->
`.github` could not promote to staging. Measured before/after on the live
fleet: 1 finding -> 0.
Not an ungated hop. `design-system-v2` does have
`.github/workflows/fr-gate-caller.yml` on develop, so the promotion gate is
present; the guard cannot tell that from a repo that joined the train without
one, and is right not to guess.
The flag is not the only cell the train join invalidated, so the reasons and
measurements around it move with it rather than being left false:
* protection.develop / staging / prod. The block was written when `develop` was
this repo's only branch. Onboarding created `staging` and `main`; measured
2026-08-27 all three return "Branch not protected" and the repo has ZERO
rulesets. The two aliases used here (`develop_unprotected_non_train`,
`no_staging_branch_not_on_train`) assert a premise that is now false, and the
prod reason ("no prod branch exists ... structural, not a gap") is simply
false. Replaced with inline, measured reasons that record the gap as
UNREMEDIATED under the open backend#2547.
* The rows stay `exempt` rather than becoming `required`: `required` over an
unarmed branch lands a red gate, and nothing in this file may land red
(arm while green). Branch first, cell after. Staleness still bites - the
moment any of the three gains protection of either kind, caller-drift flags
the exemption.
* rulesets.tag_trust_root said "no tag-triggered workflow and no v* tags cut".
Both halves are false now: `publish.yml` triggers on `push: tags: ['v*']` and
`v0.1.0-rc.1` is cut, so the trust root is MISSING, not inapplicable.
* The callers comments asserted "this repo has ONE branch, `develop`" and that
the fr-gate caller is "permanently dormant". `staging` and `main` exist, so
the caller is live and the fr-pass-comment misfire it described is gone.
Verified: caller-drift.py live findings_total=0 / "No drift" (was exit 1 with
the design-system-v2 finding against origin/develop's inventory),
caller-drift-selftest 197/197, reason-citations 0 findings, house-rules clean.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e truthful (backend#2556) (#360)
* fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#2556, backend#2616)
Two defects in one gate, fixed together because either alone leaves the other's
remedy unusable.
BACKEND#2556 -- THE GATE READS THE TITLE AND NEVER RE-RUNS WHEN IT CHANGES.
`closing-ref` decides its verdict from the PR title and body; the callers
triggered on `[opened, reopened, ready_for_review, converted_to_draft]`, and
`edited` is the only event GitHub fires when either field changes. So the two
inputs the gate reads were the two inputs that could change without re-running
it. That is a bypass, not a gap: open a PR titled `chore: tidy up`, the gate
records NOTHING_NAMED and goes green, then retitle it to `fix(1234): ...` with
nothing linked -- no event fires, the green stands. It also made the remediation
unusable in the other direction: 20 sync PRs had to be cleared with 20 manual
`gh run rerun` calls (backend#2555). Same one-word fix, same reason, as
`fr-gate-caller.yml` (backend#1945).
A CONSEQUENCE THAT HAD TO BE GUARDED, not discovered later. Every trigger this
workflow had was reachable only on an OPEN PR, so `set-status` writing Status
unconditionally was always correct. `edited` is not: GitHub fires it when a
MERGED PR's title or body is edited, and this is the one board writer in the
fleet that does not compare pipeline rank. Without the new guard, fixing a typo
in a shipped PR's description would drag its card from `Prod` back to
`Code review`. Both jobs are now `if: ...state == 'open'`.
BACKEND#2616 -- ONE SATISFYING FORM, AND A CHILD PR CANNOT TRUTHFULLY USE IT.
The checker read `closingIssuesReferences` and nothing else, and that field is
populated only by a closing keyword. So a PR whose title named a ticket had
exactly one satisfying form: promise to close it. For this org's most common
shape -- a parent ticket with several child PRs titled `type(NNNN): ...`, none
of which finishes it -- the only remedies were a FALSE `Closes`, or deleting the
number from the title, which passes the check by removing the traceability the
check exists to enforce. Four PRs in one day took the second (.github#349, #350,
#352, #353) and #354 wrote that workaround into the canon.
A declared NON-CLOSING body reference now satisfies the title, reported as its
own state (`MENTIONED`) so a green run still says which promise was made. The
closing link stays the stronger form, and `WRONG_REPO` is decided BEFORE any
body reference is consulted -- a truthful `Part of tracebloc/backend#304` must
not mask a `Closes#304` that closes `.github#304` on merge.
DERIVED, NOT RESTATED (rule 1). The non-closing vocabulary is PARSED out of
`org-standards.md`, which already declares the org's partial-work form. Add
`Refs <owner>/<repo>#N` to the canon and the gate accepts it with no code
change. The derivation fails closed in both directions: an unreadable canon and
a canon declaring no non-closing form are separate cannot-tells (exit 2), never
a silent reversion to closing-only -- reverting is the defect itself. GitHub's
closing keywords are the one list here that cannot be derived from this org, so
they are named, cited, and used only to subtract.
The canon and CLAUDE.md carried two statements this change makes false -- that
the check fails unless the body carries `Closes`, and to keep the number out of
the subject. Both are rewritten; classify() reports IN_SYNC.
EVIDENCE
closing-ref-gate-selftest 156 assertions, all passed (was 109)
closing-ref-gate-mutations 53 mutations, 0 stale, 0 uncaught (was 36)
ruff / actionlint / shellcheck / house-rules clean
standards-sync selftest 60 checks, 0 failed
The mutation harness now rewrites the two WORKFLOW files as well as the checker:
`edited` and the open-state guards are guarantees declared in YAML, and rule 5
does not exempt a guard for living in a different language.
Part of tracebloc/backend#2556
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(closing-ref): an escaped Unreadable reddens a case instead of killing the suite
The multi-word-keyword mutation was scored UNCAUGHT for the right reason: it made
`evaluate` raise, which killed the suite mid-run, and the harness refuses to
count a broken harness as coverage. `evaluate` grew a path that can raise for a
NEW reason (the derivation from org-standards.md coming back empty), so the
positive cases now go through a wrapper, exactly as `value` already does for
every other call.
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(closing-ref): the derived-vocabulary cases report a refusal instead of crashing
`value` returns the exception TEXT on a refusal, and a string is iterable -- so a
case that walked the derived list iterated characters and raised IndexError on the
space in "RAISED Unreadable: ...". Normalised once. Found by the multi-word-keyword
mutation, which is the job: it was scored UNCAUGHT for breaking the harness rather
than being caught by a case.
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…nd#2722) (#361)
* fix(kanban): a completed issue is terminal, not a deploy state (backend#2722)
Closed issues were landing in deploy columns because two mechanisms deliberately
put them there, not because either was failing.
1. kanban-closure-router.yml mirrored the closing PR's Status onto the issue, so
a PR-closed issue inherited On dev / FR on staging / Prod. Its own declared
contract said so.
2. advance-deploy-env.yml then marched those cards onward (backend#1600), because
issues parked at On dev never advanced when the code shipped and drifted
permanently -- all 20 drifted cards on 2026-08-06 were closed issues, 0 PRs.
#1600 was right that a closed issue must not be stranded and wrong about where to
put it. `Done` is terminal, so nothing can drift, and kanban-archive sweeps it off
the board daily -- which answers #1600 more completely than advancing did.
So: the router routes a completed issue to Done regardless of how it was closed,
and the closing-issue block in advance-deploy-env is removed. Both halves, because
fixing only one looks fixed while the other drags the cards straight back in.
The closer lookup goes too. It existed only to tell a hand-close from a PR-close
so they could route differently; with one destination there is nothing to tell
apart. It was also the source of two fail-wrong defects (.github#126, #127), and a
call never made cannot fail. CLOSER_TYPE / CLOSING_PR_BASE were read nowhere else.
`permission-issues` drops from advance-deploy-env with the only reads that needed it.
Checked rather than assumed: fr-gate ranks Done 11 against On dev 6, so terminal
cards satisfy both gates instead of blocking promotions.
Cost of the status quo, measured: 117 closed issues cleared out of deploy columns
by hand in one session (18 + 55 from FR on staging, 44 from On dev). Two of those
passes were functional-review batches, so the next prod payload would have read 73
items larger than the work it contained.
Guarded now, because it was not: flipping the router's destination left all 29
selftest suites green. kanban-deploy-state-selftest asserts both halves, reading
the completed arm out of the workflow rather than restating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(kanban): the weekly backstop must terminalise too (backend#2722)
Bugbot, High. The router now sends every completed issue to Done, but
kanban-reconcile.yml still derived a deploy stage from the closer`s base branch
and wrote On dev / FR on staging / Prod for exactly those cards. Fixing one
writer and not the other is worse than fixing neither: the weekly sweep would
have put every card back, the slower job silently undoing the faster one --
which is the failure .github#295`s own comment was written to prevent, arriving
from the other direction.
A deploy column is a property of a PR. A finished ISSUE belongs in Done whether
or not its fix shipped, and issue cards parked in deploy columns are what stops
kanban-archive clearing them.
The closer lookup stays, because having one still distinguishes the two cases in
the LOG -- but both arms now reach the same column, which is the rule.
Second finding, same shape one layer out: the selftest read only the router and
advance-deploy-env, so a green run confirmed the two EDITED files rather than
the invariant. It now extracts the reconcile`s closer arm too and asserts it
writes only DONE_OPT, names no deploy column, and reaches no branch->stage
mapper. Mutation-proved: restoring the On dev write reddens it.
Also removes two now-dead option ids shellcheck flagged, and the comment that
justified them, which my own change had made false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(kanban): realign the mapper selftest with a terminal completed issue (backend#2722)
The selftest already encoded #2722 for the router -- one call site, not two --
but still required kanban-reconcile.yml to keep one. Removing reconcile`s
router-miss derivation made that false, so three assertions had to follow:
* reconcile`s mapper call sites: 1 -> 0. Its only one was the closed-completed
issue arm. The merged-PR lookback still places PR cards in deploy columns but
always mapped its branches INLINE, so the count did not move because of it.
* the DEST-arm invariant is inverted rather than deleted. It asserted every
Status with an option id needed an arm, or the backstop silently skipped the
repos an override exists for (.github#304) -- true while reconcile DERIVED a
column, meaningless now there is no $DEST. It now asserts there are no arms,
plus that the extractor can still find one in a fixture, so "no arms" cannot
be confused with "the reader broke".
* the stderr-preservation loop drops reconcile, which has no stderr to preserve.
Its absence is asserted above rather than left implicit.
All 15 selftests pass and actionlint reports 0 across every workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#362)
set-pr-status.yml's header said the non-draft card moves to "In review". That
option was renamed "In review" -> "Code review" in #20 (7dbaabf) and no longer
exists on the board -- verified against the live Status field, whose options are
Backlog, North Stars, Ready, In progress, Code review, On dev, Staging (agent
review), FR on staging, Ready for prod, Prod, Done, Cancelled.
The code has been correct throughout: the job writes `status_name=Code review`
(:108), and the same header already says "the SAME event that puts a card in
`Code review`" thirty lines further down. So the file disagreed with itself, and
the wrong half is the part a reader meets first.
Comment-only. No workflow logic, no behaviour change.
Swept the repo for the same phrasing; this was the only board reference. Two
other matches are deliberately left alone: repo-inventory.yml:635 uses "while
this PR was in review" to mean review as an activity, which is correct English
and not a column name, and standards-sync-selftest.py:428 is a Python `in`
operator, not the phrase.
make check: 95 passed 0 failed, 29 passed 0 failed, EXIT=0.
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@tracebloc-release-traintracebloc-release-trainBot added the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Aug 27, 2026
Comment threadscripts/standards-sync.py
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

REFUSED. This promotion was blocked by 1 HIGH severity finding(s): Create failure aborts remaining classification, per the severity policy in release-train's README (High stops the line; an unreadable severity blocks too, because unknown is not low; Medium/Low are recorded and ship, at both hops).

Blocking threads stay UNRESOLVED by design, so this promotion remains blocked until they are fixed on develop. This is a third look at twice-reviewed code -- it passed per-feature review and functional review on the source branch before the promotion.

What the train did with each:

@tracebloc-release-traintracebloc-release-trainBot removed the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Aug 27, 2026
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

REFUSED. This promotion was blocked by 1 HIGH severity finding(s): Create failure aborts remaining classification, per the severity policy in release-train's README (High stops the line; an unreadable severity blocks too, because unknown is not low; Medium/Low are recorded and ship, at both hops).

Blocking threads stay UNRESOLVED by design, so this promotion remains blocked until they are fixed on develop. This is a third look at twice-reviewed code -- it passed per-feature review and functional review on the source branch before the promotion.

What the train did with each:

…d#2637) (#359)
* ci(conflict-gate): a conflicted PR reads red, not empty-green (backend#2637)
A PR with a merge conflict against its base dispatches NONE of its
`pull_request` workflows: GitHub cannot compute the merge ref those runs are
keyed on. Every drift and source-of-truth guard is therefore silently inactive
on exactly the PRs most likely to need it, and the rollup shows nothing red
because nothing ran.
Measured on model-zoo#206 while it was DIRTY against develop: 0 workflow runs
on its head sha, one rollup entry (Cursor Bugbot, SUCCESS), and all seven of
model-zoo/develop's required contexts absent. PRs #204 and #205 either side of
it got their full matrix.
Adds an org-wide sweep that writes a commit status onto each open PR's head sha
- failure when conflicted, success when clear, pending when GitHub will not say
- which is the only signal that can reach a PR with no merge ref.
Not required anywhere yet, deliberately: a 30-minute sweep would leave a
freshly-opened PR at "Expected - waiting" until the next run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(conflict-gate): a case that CRASHES is not a case that CAUGHT (backend#2637)
Two mutations -- drafts skipped in plan(), and an unreadable PR list returning
no error -- emptied a list the suite then indexed, so it died with an IndexError
instead of reporting a FAIL. The harness correctly scored both as 'broke the
harness' rather than caught: a traceback proves nothing about coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(conflict-gate): record the green-but-conflicted shape the watcher cannot see (backend#2637)
backend#2257, measured live and CONFLICTING, carries 8 workflow runs and all
eleven of backend/develop's required contexts present and SUCCESS -- computed
against a merge base that no longer exists. bricked-prs.py reasons from an
ABSENT required context, so with nothing missing it cannot see this PR at all.
That is the case that justifies asking about mergeability directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(conflict-gate): a status is written only when it would change something (backend#2637)
GitHub caps statuses at 1000 per sha AND context. A 30-minute sweep is 48
writes a day onto an unchanged head, so a PR left open three weeks would
exhaust the cap and every later write would 422 -- the gate going silent on
exactly the stalest PRs, which are the ones most likely to have conflicted.
The current state is read out of statusCheckRollup, which the PR list already
returns, so this costs no extra API call. A truncated rollup omits our context,
which reads as 'no status yet' and produces a write -- the safe direction.
The case fold is load-bearing: GraphQL reports SUCCESS, the Statuses API takes
success, and comparing unfolded would make every status look changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(conflict-gate): assert the workflow is ARMED, not merely present (backend#2637)
Everything the suite asserted proved the SCRIPT was right; none of it proved
anything RUNS it. Adds nine assertions parsing conflict-gate.yml as YAML (a
"run:" line under a comment or an "if: false" greps identically to a live one)
and six workflow mutations, so the harness now rewrites both files.
The regression most likely to be made in good faith has its own mutation:
someone asks why this does not run on PRs, adds a "pull_request:" trigger, and
the gate becomes inert on exactly the conflicted PRs it exists for while looking
more thorough. Rule 5 does not exempt a guarantee for being written in YAML.
The baseline guard now covers both targets: a mutation left in the workflow by a
killed run would become the next run's premise just as silently as one left in
the script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(conflict-gate): read the current status via REST, not the rollup (Bugbot, #359)
Bugbot raised this as high on the previous head, and it was right.
"gh pr list --json statusCheckRollup" resolves "commit.status" underneath, and
GraphQL REFUSES that subfield on a PRIVATE repo unless the token also holds
"actions: read" -- measured under backend#2157 and documented in
bricked-prs.yml, which declares "permission-actions: read" for exactly this
reason. This gate's mint deliberately holds only pull-requests:read and
statuses:write, so the dedup added in c3b8279 would have made open_prs raise on
every private repo in the org: each becomes COULD NOT EVALUATE and the sweep
exits 2 having judged almost nothing.
The trap was documented in a file I read while writing this one, which is the
argument for measuring a narrow token rather than reasoning about it.
Reads "GET /repos/{o}/{r}/commits/{sha}/status" instead. It reads commit statuses
and nothing else, so the statuses permission already held covers it; it cannot be
refused for a scope this job has no other use for; and it has no pagination cap to
straddle. One GET per open PR, ~50 per sweep. The mint stays at two permissions.
Two new mutations pin it: switching back to the rollup, and treating an
unreadable current state as agreement (which would silently stop reporting
whenever the status read flakes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 50c8f65. Configure here.

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

Labels

gate-nudgeToggled by the release train to (re-)fire the fr-gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@LukasWodka