feat(pm): a scheduled backup of the board, so an account suspension destroys no record - #17404
Merged
Conversation
…estroys no record (#17390) Three fleet accounts were suspended in two months and the appeals are unanswered after ten days. #17374 F3 measured what a suspension destroys: every issue, pull request and comment the account authored — while every branch and commit survives, because those belong to the repository rather than to a user. The board IS the fleet's state by rule, so one suspension erases state nothing else holds; six cards are already gone, one of them a p1 security decision. `scripts/pm/board-snapshot.mjs` reads the board over repo-scoped REST with whatever token the caller supplies and writes one JSON file per number, its comment thread as JSONL, a pull request's reviews beside it, and a manifest carrying the run stamp, the `since` used, counts by state, the board's own `open_issues_count` read in the same run, the request count and a resume cursor. Runs are incremental from the previous manifest and idempotent: a re-run over an unchanged board writes nothing at all. Two properties are load-bearing and neither is visible on a clean tree. The page walk never trusts `Link: rel="next"` and ends only on a short page, and it re-anchors `since` on the last row's VALUE rather than incrementing an offset — an item updated mid-walk shifts every row behind it one slot, and a blind `page += 1` skips the row at the boundary permanently, its own `updated_at` never having moved. The other is the rate discipline: a refusal stops the run, writes the cursor and exits non-zero with the reset time, and nothing here retries, because a loop against a spent budget starves every other automated caller in the repository for the rest of the hour. `--restore <n>` prints, and only prints, the recreate payload for a destroyed card: the provenance header a rebuilt record must carry, the archived body, the labels to re-apply and the comment thread as a second block. Posting stays a seat's act, and the script has no write path to GitHub in any mode — two self-test cases read its own source and hold that structurally, which is the mechanical half of the one-board rule the archive lives under. `.github/workflows/board-snapshot.yml` is the standing caller, at the half-state patrol's cadence, running as `github-actions[bot]` into the orphan branch `board-archive`. It never writes to `main` and holds no scope that would let it write to any issue. Self-test: 70 cases across 8 batteries, offline, no network and no token, wired into the required lint job as `check:pm-board-snapshot`. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
… nothing `check:pm-dispatch-gates` pins that no discovered gate family is reachable only through a schedule — every patrol in this tree carries a `pull_request` paths trigger, and a workflow whose script CI never runs at PR time reddens that gate by existing. The first draft of `board-snapshot.yml` omitted the trigger to keep PR-time cost at zero and was refused by exactly that pin, which named the remedy in its own failure text. So a pull request now runs the snapshot on a real runner — transport, flags, page walk and rendering, proven where they will actually run — into the runner's temp dir, `--dry-run`, capped at five numbers and twenty-five requests, with the archive checkout and the commit both skipped. A pull request writes to neither the board nor the archive, and the two spellings differ in where they write and in nothing else, so what a PR exercises is the path a schedule takes. Two readings from the local drill, folded back in: the PENDING count-check line no longer prints an arithmetic the run never read (a stopped run skips the board's own count, and `expected 0` read as a real zero), and `--restore` no longer re-execs itself through the proxy — it makes no request at all, so routing its transport spawned a child to prove a route that mode never uses. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 10, 2026
os-litant
marked this pull request as ready for review
September 10, 2026 12:22
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 free
to 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.
Fixes #17390
Three fleet accounts were suspended in two months and the appeals are unanswered after ten days. #17374 F3 measured what a suspension destroys: every issue, pull request and comment the account authored — while every branch and commit survives, because those belong to the repository rather than to a user. The board IS the fleet's state by rule, so one suspension erases state nothing else holds; six cards are already gone, one of them a p1 security decision.
This is the tool that stops the next loss. It is a scheduled CI backup, which is the shape the maintainer named in chat on 2026-09-10, verbatim and untranslated: 「或者用 ci 写个定时任务备份?」
An archive is not a tracker — the one-board rule stands
Nothing reads
board-archivefor state. Not a seat, not a patrol, not a gate, not a query; every reading of the board still goes to GitHub, and the pm-dispatch invariant 「GitHub 之外永不维护任何跟踪状态」 is untouched. The archive answers exactly one question, after a loss: what did the record say?That is enforced rather than promised:
method:naming a mutating verb, and exactly onefetchcall site, the read helper.--restorePRINTS a recreate payload; posting stays a seat's act.contents: writeis for the archive branch; the other two scopes areissues: readandpull-requests: read. It never writes tomain.Layout
One file per number, every record built key by key in a fixed order and every list sorted, so two runs over an unchanged board produce byte-identical files and the branch history is a diff of what actually changed.
Runs are incremental from the previous manifest and idempotent: a re-run over an unchanged board writes nothing at all, so there is no per-run no-op commit burying the real diffs. The liveness signal is therefore the Actions run history and each run's job summary, never a timestamp in the branch — stated in the script header so nobody reads the archive for a heartbeat it deliberately does not carry.
The page walk, and the skip it is built to avoid
The walk never trusts
Link: rel="next"— the REST channel table's measurement — and ends only on a short page. It also re-anchorssinceon the last row's VALUE rather than incrementing an offset: an item updated mid-walk moves to the end of the ordering and shifts every row behind it one slot, so the row that was about to be the first of page k+1 lands at the last slot of page k, which the run has already read past. A blindpage += 1skips it permanently — its ownupdated_atnever moved, so no latersincewindow contains it either.sinceis inclusive, so the re-anchor re-reads the boundary rows and writes them identically; an over-read, which is the safe direction.Rate discipline
GITHUB_TOKENis 1,000 requests/hour per repository and this job is not its only caller. The run stops at its own--max-requestsceiling (default 800), writes a resume cursor into the manifest and exits 0; the next scheduled run continues from that cursor, so the first full snapshot walks the backlog across several days without ever exceeding the budget. On a real rate-limit refusal it stops, writes the same cursor, prints the reset time and exits non-zero — and nothing retries, because a loop against a spent budget starves every other automated caller in the repository for the rest of the hour.Measured request cost per scheduled run
Comfortably inside the 800-request cap and the 1,000/hour budget, which is what Zone 2C asked to be stated from a measurement rather than an estimate. The anchor's ~154–415 closed-issue updates/day is the closed half only; the 187 above is the whole board, closed and open, issues and pull requests.
Can the bot actually push to
board-archive? — measuredGET /repos/objectstack-ai/objectstack/rulesets?includes_parents=truereturns exactly one ruleset:No parent (organization) ruleset applies.
board-archiveis not~DEFAULT_BRANCH, so no rule covers it and acontents: writetoken pushes to it unrestricted.GET /repos/.../branches/main/protectionanswers403 Resource not accessible by integrationfor this credential, so the ruleset read is the reading available and the classic protection API is declared unread rather than assumed empty. Corroborating from the other side: this branch's own push carried a new file under.github/workflows/, and it was accepted — a different actor from the workflow token, so it corroborates the ref-pattern half only.Local drill
Run narrow on purpose (Zone 2D): the user token must not perform a full-board pull, and the first full snapshot belongs to the bot across resumable runs.
Snapshot, 2-hour window,
--limit=20— 20 numbers in 29 requests, 35 files written, walk paused by the limit with a resume cursor at2026-09-10T10:06:38Z, count checkPENDING(a census of a partial archive is no reading about the board — and it renders aspending, never as a pass).Idempotence — the identical command re-run:
written 0 file(s),idempotent nothing changed on the board since the last run.Count check, live —
--verify-countsenumerated 584 open issues by walking pages to a short page; the board reportsopen_issues_count 604minus 20 open pull requests = 584.count check ok — the enumeration is a reading.9 requests. This is the acceptance criterion's arithmetic, exercised against the live board today rather than after the first full snapshot completes.Restore drill, byte-for-byte —
--restore 17350on a closed low-value card. The archived body and the live body are byte-identical:The printed payload (20,648 bytes) contains that body verbatim, carries the provenance sentence, renders three comment blocks, and makes zero network requests — it reads the archive directory and nothing else, which is what lets it run from a bare checkout of
board-archive.The workflow's own git mechanics, rehearsed locally — the orphan branch is the part no unit test reaches, so both of its paths were driven in a throwaway repository with the exact commands the job runs. First run:
git worktree add --detach archive HEAD,git checkout --orphan board-archive,git rm -rf --quiet .leaves 0 tracked files (the job asserts that rather than assuming it), the archive commit has no parent — a true orphan — its tree isboard/...and nothing else, andmainis untouched. Second run against a pushed branch: the fetch findsrefs/remotes/origin/board-archive, the worktree checks it out, and with no change on the board the commit step reportsno change on the board since the last run — nothing committed.Total REST requests for the whole drill: 76 (67 by the tool across three runs, 9 by hand for the readings above).
Self-test
70 cases across 8 batteries, offline, no network and no token, wired as
check:pm-board-snapshotinto the requiredLint & Repo Gatesjob the way #17326 wired its sibling. What it instruments is the set of rules a clean tree cannot exercise: the walk's value re-anchor, the resume cursor, the idempotence, the count check's three verdicts includingpending, the provenance sentence, the refusals (no token, no board, an unknown flag), and the two structural properties above.Gates — derived, run, reconciled at
0c5c3f08node scripts/pm/dispatch-gates.mjs --commandsderived 64 families from this diff; every one was run with its exit code captured before any pipe, and the record was reconciled back through--ran:The five are
check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:sourcemap-no-sources-contentandcheck:type-check-debt— each exited 3, PREREQUISITE NOT MET, because it reads builtdist/output and this tree is unbuilt. They are matched here only through theBuild Corejob'spackage.jsonfilter, and this diff'spackage.jsonchange is onescriptsentry, which cannot move any package's build output. NOT MEASURED, not green; CI builds and runs them.Two readings worth naming:
check:pm-dispatch-gatesfailed on the first draft (3 of 1674 cases) and is the reason the workflow carries apull_requesttrigger — see the first acceptance note. It passes on this tree:dispatch-gates self-test: 1674 cases pass.pnpm lint(eslint . --no-inline-config) exits 0 with no output at this head.node scripts/pm/check-governed-merges.mjs --teston this file list: NOT governed —scripts/pm/**and.github/workflows/**are not on the register, so this lands through the queue in the ordinary way.Acceptance notes
pull_requesttrigger is not optional here. The first draft of the workflow omitted it to keep PR-time cost at zero;check:pm-dispatch-gatesrefused it, because that gate pins that no discovered family is reachable only through a schedule. A pull request now runs the snapshot on a real runner into the runner's temp dir,--dry-run, capped at five numbers and twenty-five requests, with the archive checkout and the commit skipped — it writes to neither the board nor the archive.closed_byis declared absent, not silently missing. The listing endpoint does not carry it; buying it would cost one extra request per archived number, which is the whole budget spent on a field no restore needs. The key is present andnullso a reader can tell "not carried by this archive" from "nobody closed it".gone.jsonis its only quieting. An archive holding more open issues than the board is the destruction signature this tool exists for. A number deliberately left destroyed, or transferred to another repository, goes in the hand-written ledger; nothing else silences it, so the alarm cannot decay into noise a run learns to ignore.--restoreis a recipe, not an act. It prints; a seat posts. The header the payload carries states the original number, the author, both timestamps and that the record is a rebuild — 共享身份的限流纪律不存在:一次限流信号约束的是「身份」不是「客户端」,而规矩只说了不要重试 —— 2026-09-10 全 fleet 停摆事故 #17374 F3.4 — so a restored card can never be mistaken for the original.half-state-patrol.yml's adopt list documents copying two files where the sweeper's imports require three, corrected in that file's own header on 2026-09-03. This diff copies the corrected posture (a four-file adopt list derived from the imports, with a line saying the imports decide it and not the comment) rather than touching that file.Authored by the skills seat's dispatched developer session
session_01YKEjmbYNvYWJvWGSWx26zK(https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK). Attribution is written as prose here on purpose: measured on this pull request, a REST edit of the body APPENDS the platform's own bare footer block, so a session-URL footer sent with an edit leaves two. Sent without one, the block below is the platform's and there is exactly one.Generated by Claude Code