Uh oh!
There was an error while loading. Please reload this page.
sec(mint-scope): the weekly backstop drops contents:write across the fleet - #353
Merged
Merged
Conversation
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>LukasWodka
requested review from
aptracebloc and saadqbal
and removed request for
saadqbalAugust 26, 2026 15:48
backend#2157. `set-pr-status.yml` landed on develop (#349) and dropped its own EXEMPT row; this branch drops `kanban-reconcile.yml`'s. Git saw one line changed twice and called it a conflict -- both deletions are correct, so the resolution keeps NEITHER row. EXEMPT is now fr-gate + standards-sync, and the prose above it is updated to say two rather than four. It already said four on develop while three remained, so this fixes a drift it did not introduce -- in the commit that would otherwise have made it twice wrong. Verified: make lint exit 0 (mint-scope 2 unscoped / 2 exempted / 0 findings, "exemptions all still apply"), make selftests exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
saadqbal
approved these changes
Aug 26, 2026
saadqbal
left a comment
Collaborator
There was a problem hiding this comment.
The right call is the one you didn't make — claiming this read-only would have broken the Monday run, and you checked instead: three ProjectV2 mutations, label creation, label application. Dropping contents to read while keeping the three writes it actually uses is the real win, since contents:write is the one that can push code to any repo in the installation.
Keeping pull-requests: write unmeasured-but-retained is also right — guessing it short would fail on a path that only runs weekly. 👍
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
backend#2157. #353 (kanban-reconcile) landed on develop and dropped its EXEMPT row; this branch drops standards-sync's. Same adjacent-deletion conflict as the last merge -- both deletions are correct, keep NEITHER. fr-gate is now the ONLY exemption left, so the prose says one rather than two and names why fr-gate is last: it is a REQUIRED check on every promotion branch, where a bad scope reddens every hop at once. Verified: make lint exit 0 (mint-scope 1 unscoped / 1 exempted / 0 findings, "exemptions all still apply"), make selftests 29 passed 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
backend#2157. #353 (kanban-reconcile) landed on develop and dropped its EXEMPT row; this branch drops fr-gate's. Adjacent-line deletions, so git called it a conflict -- both are correct, keep NEITHER. standards-sync is now the only exemption left, so the prose says one rather than two and names why it is last: a contents:write sweep whose risky mode cannot be rehearsed without opening 19 real PRs. NOTE FOR WHOEVER MERGES SECOND: #355 drops standards-sync and resolves the same dict to {fr-gate}. The two are mutually exclusive by construction -- whichever lands first, the other needs one more develop merge. That is the cost of scoping four mints in four PRs and is not a defect in either. Verified: make lint exit 0 (mint-scope 1 unscoped / 1 exempted / 0 findings, "exemptions all still apply"), make selftests 29 passed 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
…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>
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
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>
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
…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>
LukasWodka added a commit
that referenced
this pull request
Aug 27, 2026
…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>
LukasWodka added a commit
that referenced
this pull request
Aug 28, 2026
…says which (backend#2586) (#369) * fix(bugbot-gate): an unclaimed head means two things, and the author says which (backend#2586) The UNCLAIMED report told every reader the same thing: that Bugbot "drops PRs" and that "no one here can fix" it. Re-measuring says that is true of exactly one class -- a PR a Bot authored -- and false of every other, where an absent review is unprecedented and the reader should re-run. Measured 2026-08-27, 662 PRs across 20 repos (plus a 2026-07-01 sweep for the bot rows), anchored on the head COMMIT rather than PR creation: PR author head commit author n got a Cursor Bugbot check human human 594 yes (all) human release-train[bot] 43 yes, 43 of 43 Bot (App) human 3 yes, 3 of 3 Bot (App, sync) release-train[bot] 20 NO, 0 of 20 Bot (dependabot) either 15 NO, 0 of 15 So the discriminator is the PR's AUTHOR, and the head commit's author does not matter. backend#2114 closed saying "no discriminator survives the data"; one does. The measurement the previous comment carried is retracted in place rather than deleted: .github#349/#350/#352/#353 and e2e-test-agent#273 were cited as never reviewed, and every one of them carried a COMPLETED Cursor Bugbot run on every commit it ever had -- this gate's own `bugbot / review` concluded SUCCESS on their heads. Timing latency from PR creation while the head had moved is the likely cause of the original reading. No exit code changes. UNCLAIMED still exits 0 for every author kind, so whether the HUMAN case should block stays an open decision on backend#2586 rather than being smuggled in here. - author_kind derives the branch from GitHub's answer; an unmeasured actor type and a deleted account are "cannot tell", never folded into either neighbour - query_lacks_author_kind refuses the run if the query stops asking for author.__typename, so the discriminator cannot go inert while green * fix(bugbot-gate): "the author decides" was itself an overclaim (backend#2586) The first commit said the discriminator is the PR's author and that the head commit's author does not matter. Its own table contradicts that: 3 of 3 Bot-opened release-train promotion PRs, whose head COMMIT was human-authored, WERE reviewed. Neither single field decides. What fits all three sweeps -- and no simpler rule does -- is that a PR is reviewed when SOME identity on it has a Cursor seat, author or head-commit author, with dependabot's never reviewed either way (0 of 15, including 2 with human head commits). So the three sweeps are now recorded separately, with their own windows and repo sets, instead of merged into one tidy table; the human count is corrected to 635 of 635 non-draft (637 human-authored, 2 of them drafts); and the Bot paragraph names the measured exception instead of asserting that no Bot-opened PR is ever reviewed. The branch still keys on the author, which is the field that identifies the class with no remedy -- but it now says what was measured rather than rounding it off.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
kanban-reconcile.ymlwas minting the App's entire permission set — includingcontents: writeacross every repo the installation covers — to run the weekly board backstop.Narrowed, not made read-only
This one genuinely writes, and I checked rather than assuming:
addProjectV2ItemById,updateProjectV2ItemFieldValue,archiveProjectV2Item)POST repos/{o}/{r}/labelsforkeep-openandsibling-mergePOST repos/{o}/{r}/issues/{n}/labelsClaiming it read-only would break the Monday run.
The win is
contentscontents: writeis the capability that can push code to any repo. This job's only content access is reading.github/workflows/add-to-kanban.ymland a couple of branch refs.pull-requests: writeis 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 is the one that is provable without a hop
.github's default branch isdevelop, crons run from the default branch, and this workflow has adry-rundispatch input. So once this merges:exercises the real mint and every read path with no mutation. That is why this went ahead of
standards-sync: it is the only one of the four where the evidence that matters — the token actually working — is obtainable the same day rather than afterdevelop → staging → main.It also directly answers the trap
release-train/add-to-kanban.ymlrecords:A dry run proves the reads. The writes are still only proven by the first real Monday run — I'm not going to claim otherwise.
Not closing the ticket
Three of four. Remaining:
standards-sync.yml, which is report-only onschedule/push/issues/pull_requestbut mintsContents: RWfor thecreate-prsdispatch mode. That is the largest mismatch left and the one whose risky mode cannot be rehearsed without opening 19 real PRs — it deserves its own decision, not a batch slot.Test plan
make lint— cleanmake selftests— 28 passed, 0 failedactionlint— cleanmake mint-scope— 3 unscoped / 3 exempted / 0 findingsdry-run=truedispatch (reads + mint). Writes confirmed by the first scheduled run.Part of tracebloc/backend#2157
Note
Medium Risk
Changes least-privilege on a workflow that still writes labels and board state weekly; a wrong scope would fail the Monday reconcile rather than at merge time, though
dry-runcan validate reads after merge.Overview
Narrows the weekly
kanban-reconcileGitHub App installation token from the App’s full grant to explicitpermission-*inputs onactions/create-github-app-token.The main reduction is
contents: write→contents: read, since the job only reads workflow files and branch refs while still needingissues/pull-requestswrite for label create/apply andorganization-projectswrite for ProjectV2 mutations.administration/actions/checksread are no longer requested.Updates
scripts/mint-scope.pyto removekanban-reconcile.ymlfromEXEMPTand refresh the burn-down notes so onlyfr-gate.ymlandstandards-sync.ymlremain exempt from the explicit-scope guard.Reviewed by Cursor Bugbot for commit 5e01b15. Bugbot is set up for automated code reviews on this repo. Configure here.