Skip to content

feat(pm): a scheduled backup of the board, so an account suspension destroys no record - #17404

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-17390-board-snapshot
Sep 10, 2026
Merged

feat(pm): a scheduled backup of the board, so an account suspension destroys no record#17404
os-litant merged 2 commits into
mainfrom
claude/issue-17390-board-snapshot

Conversation

@os-litant

@os-litant os-litant commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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-archive for 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:

  • The script has no write path to GitHub in any mode. Two self-test cases read its own source and hold it structurally — no method: naming a mutating verb, and exactly one fetch call site, the read helper. --restore PRINTS a recreate payload; posting stays a seat's act.
  • The workflow holds no scope that could write to the board. contents: write is for the archive branch; the other two scopes are issues: read and pull-requests: read. It never writes to main.
  • The archive is a branch, which is precisely the thing a suspension leaves standing, in this same repository — no second repo and no second credential.

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.

board/issues/N.json      the issue or pull request record
board/comments/N.jsonl   its comment thread, one JSON object per line, id-ordered
board/reviews/N.jsonl    pull requests only: reviews and review comments, each
                         line discriminated by its `record` field
board/manifest.json      run stamp, the `since` used, counts by state, the
                         board's own open_issues_count read in the same run,
                         the request count, the resume cursor
board/gone.json          hand-written: numbers known destroyed or transferred

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-anchors since on 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 blind page += 1 skips it permanently — its own updated_at never moved, so no later since window contains it either. since is 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_TOKEN is 1,000 requests/hour per repository and this job is not its only caller. The run stops at its own --max-requests ceiling (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

reading value how
numbers updated in a real 6-hour window 187 listing walked to a short page at 2026-09-10T11:52Z, 2 requests
requests per number 1.45 drill: 20 numbers cost 29 requests (1 listing page + 14 comment fetches + 7 pull requests x 2 review reads)
projected steady-state run ~285 requests 187 x 1.45 + 2 listing pages + 10 for the count check

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? — measured

GET /repos/objectstack-ai/objectstack/rulesets?includes_parents=true returns exactly one ruleset:

name main · enforcement active · target branch · source_type Repository
conditions.ref_name.include = ["~DEFAULT_BRANCH"]   exclude = []
rules: deletion, non_fast_forward, merge_queue, pull_request, required_status_checks

No parent (organization) ruleset applies. board-archive is not ~DEFAULT_BRANCH, so no rule covers it and a contents: write token pushes to it unrestricted. GET /repos/.../branches/main/protection answers 403 Resource not accessible by integration for 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 at 2026-09-10T10:06:38Z, count check PENDING (a census of a partial archive is no reading about the board — and it renders as pending, 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-counts enumerated 584 open issues by walking pages to a short page; the board reports open_issues_count 604 minus 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 17350 on a closed low-value card. The archived body and the live body are byte-identical:

    live  bytes 8156  sha256 a7ae5631e03efd4fa7056a1d29a4ac2793c6c6b21348a504ba58ea2e680e665b
    arch  bytes 8156  sha256 a7ae5631e03efd4fa7056a1d29a4ac2793c6c6b21348a504ba58ea2e680e665b
    cmp: byte-for-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 is board/... and nothing else, and main is untouched. Second run against a pushed branch: the fetch finds refs/remotes/origin/board-archive, the worktree checks it out, and with no change on the board the commit step reports no 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-snapshot into the required Lint & Repo Gates job 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 including pending, the provenance sentence, the refusals (no token, no board, an unknown flag), and the two structural properties above.

Gates — derived, run, reconciled at 0c5c3f08

node scripts/pm/dispatch-gates.mjs --commands derived 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:

✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 59 run, 5 NOT-MEASURED
  (5 DERIVED from a recorded exit 3).

The five are check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content and check:type-check-debt — each exited 3, PREREQUISITE NOT MET, because it reads built dist/ output and this tree is unbuilt. They are matched here only through the Build Core job's package.json filter, and this diff's package.json change is one scripts entry, which cannot move any package's build output. NOT MEASURED, not green; CI builds and runs them.

Two readings worth naming:

  • check:pm-dispatch-gates failed on the first draft (3 of 1674 cases) and is the reason the workflow carries a pull_request trigger — see the first acceptance note. It passes on this tree: dispatch-gates self-test: 1674 cases pass.
  • Repo-wide lint was run in full rather than narrowed: pnpm lint (eslint . --no-inline-config) exits 0 with no output at this head.

node scripts/pm/check-governed-merges.mjs --test on this file list: NOT governedscripts/pm/** and .github/workflows/** are not on the register, so this lands through the queue in the ordinary way.

Acceptance notes

  • The pull_request trigger is not optional here. The first draft of the workflow omitted it to keep PR-time cost at zero; check:pm-dispatch-gates refused 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_by is 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 and null so a reader can tell "not carried by this archive" from "nobody closed it".
  • The surplus verdict is red on purpose, and gone.json is 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.
  • Six-hourly is the accepted loss window. A card created and destroyed inside one interval was never archived. Nothing cheaper than a webhook closes that, and it is a different card.
  • --restore is 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.
  • Noted, not filed: 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

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants