Skip to content

feat(pm): pace every seat write — one shared throttle in front of the five write scripts (#19572) - #19584

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-19572-write-pace
Sep 21, 2026
Merged

os-project-manager merged 3 commits into
mainfrom
claude/issue-19572-write-pace

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #19572

Clause-②: no

Filed and dispatched by the director seat under the maintainer's verbatim ruling:

截流应该要加

(chat, 2026-09-21) — the sentence #208 R6 requires before a new instrument. Two seat accounts
were suspended by the platform earlier the same day during bulk issue operations. This PR makes
pacing a property of the write path rather than of who remembered it.

What lands

scripts/pm/write-pace.mjs — one helper, two calls, no third responsibility. It reads no
body, judges no card and changes nothing a write says. It answers one question, asked immediately
before a request leaves: may this write go now, and if not now, after how long a pause — or not at
all?

The three rules, defaults as the charter states them:

rule default what happens
① minimum gap 3 s per token the remainder is slept, and the pause prints one line saying so
② trailing-hour budget 40 writes per token the 41st is REFUSED — exit 10, with a prescription. ⛔ Never a silent wait
③ back-off marker 30 min, or retry-after when that reaches further every write by that token is refused, with the marker's expiry printed, until it expires

The log is ~/.cache/objectstack-pm/write-pace.jsonl (OS_PM_WRITE_PACE_FILE moves it), one JSON
line per write, keyed by tokenKey() — a 12-hex prefix of the token's SHA-256. ⛔ The token itself
never reaches the file, in any field. OS_PM_WRITE_MIN_GAP_MS and OS_PM_WRITE_HOURLY_MAX override
① and ② for tests; a malformed override is IGNORED and said out loud, because an override that
silently loosens a budget is worse than one that is refused.

Exit 10 is distinct from every code the five calling tools use (their highest is 9), so a seat
reading an exit code can always tell "the throttle stopped me" from anything the tool itself decided.

Four design points worth reading before the diff

  • classifyHttp is reused by VALUE, never re-derived. label-write.mjs already owns "what does
    this status mean", and its ratelimit arm is handed to noteResponse as verdict by the two
    callers that already hold it. ⛔ It is not imported: that module pulls the half-states patrol in
    behind it, and a throttle every write path loads must not drag a 26k-line gate into every process.
    What the throttle adds is the signals that classifier does not answer, and none of them is a copy
    of it — a 429 whose quota is not exhausted, a 403 whose BODY names the secondary limit, a bare
    retry-after, and x-ratelimit-remaining: 0 on ANY status including the 200 that spent the last unit.
  • Reserve, then sleep. The decision and the record of it are taken under one mkdir lock, and the
    sleep happens after the lock is released: the record's timestamp is the instant the write will be
    ISSUED. A second process asking while the first is still sleeping sees the reservation and paces
    behind it. The lock FAILS OPEN — it can only ever cost precision, never a seat's ability to write.
  • Dry runs are excluded STRUCTURALLY. This file has no idea what a dry run is and must not. The
    guard is isWriteMethod() in each transport, and every --dry-run path in those tools returns
    before it issues a write request at all. Proven below with a positive control, not asserted.
  • Every line goes to stderr. post-stamped.mjs prints the rendered body to stdout and --json
    after it; a pacing line on stdout would corrupt both. --status is the one command whose report
    is stdout, because reading it IS the output.

The census of write sites, and each one's two calls

git grep -n "method: 'POST'|'PATCH'|'PUT'|'DELETE'" scripts/pm finds twelve write sites in five
files. The wiring is per transport, not per site, so all twelve are covered by five pairs:

file transport write sites it issues paceWrite before noteResponse after
post-stamped.mjs rest comment POST (2586), body PATCH (2590) 2547 2565 (refusal path, carrying the platform's own sentence) · 2573 (2xx path)
label-write.mjs rest label POST/DELETE, assignee POST/DELETE, the whole-set PATCH fallback, the re-add POST (538–541, 585, 626) 335 363, with classifyHttp's verdict handed over
close-cards.mjs rest the closing PATCH (651) 445 471, with classifyHttp's verdict handed over
sweep-closed-cards.mjs rest one DELETE per residue label (622), one comment POST (633) 502 517 (refusal path) · 522 (2xx path)
sweep-stale-finding.mjs restLive the one DELETE that strips the stale grade (424) 360 374 (refusal path) · 381 (2xx path)

Line numbers are the post-merge ones. close-cards.mjs posts its comment by spawning
post-stamped.mjs as a child, so that write is paced by post-stamped's own transport rather than
twice. ensure-pm-labels.sh runs through gh under the maintainer's hand and stays out, as the
card directs.

On the two sweeps and post-stamped's refusal path the response body is now read before the throw —
only to be CLASSIFIED, because a secondary-rate-limit 403 names itself in prose and nowhere else.
The error objects carry exactly what they carried before.

The self-test — 64 cases across 9 batteries

node scripts/pm/write-pace.mjs --self-test — offline, injected clock, injected sleep, injected
exit; nothing waits three real seconds and nothing touches the real cache file. Floor is the battery
ROSTER, not a total, and the verdict sets selfTestReachedVerdict as its last statement.

battery floor what it pins
the token key: enough to separate identities, useless as a credential 6 12-hex prefix, determinism, two tokens are two budgets, ⛔ the key is not a prefix of the token, a token-less path still paces
the write-verb predicate: a read is never paced 5 the four verbs, GET/HEAD/omitted, and that case is not a way to dodge it
the limits: overrides are for tests, and a malformed one never loosens 6 defaults are the contract; 4O falls back to 40 AND says so; -1 is malformed; 0 is a real value
rule ①: the remainder is slept, and the pause is said out loud 7 first write does not pause; a write 1 s later sleeps 2 s not 3; after the gap, zero; the gap is PER TOKEN
rule ②: the 41st is REFUSED, with a prescription 8 forty pass, the 41st refuses at exit 10, it did NOT sleep instead, the text names the count / the reopen instant / what to fold, and a refused write reserves NOTHING
rule ③: what writes a stop marker, how long it lasts, what it refuses 10 429, secondary-limit 403, x-ratelimit-remaining: 0 on a 200, classifyHttp's verdict as an input; ⛔ a plain 403 writes none; a long retry-after wins and a short one does not shorten; refused while live, allowed once expired
the prune: the trailing hour for writes, the expiry for markers 6 old writes dropped, an EXPIRED marker dropped, ⛔ an unexpired two-hour marker survives the hour it outlives, a torn line is dropped not fatal, and the file on disk is rewritten pruned
the log, --status and the CLI: what lands on disk, and what must never 9 THE case — the token never appears in the file; --status counts the window, names the marker, never prints the token and spends no budget; the CLI exits 0, and a typo is usage
the wiring: both halves, in every write transport, on write verbs only 7 the five tools each import the throttle and call both halves under isWriteMethod; and the roster IS the set of files under scripts/pm that issue a write verb, so a SIXTH write path cannot land unpaced

That last battery masks comments through the shared js-comment-mask (imported inside the battery,
so the hot path stays a throttle) and skips any line carrying a regex-delimiter spelling of the
pattern — three gates in that directory pin their own report-only-ness against a regex literal of
exactly this shape, and reading those as write sites would demand a throttle on a file that cannot
write at all.

✓ write-pace self-test: 64 cases pass across 9 batteries — the per-token key that never carries the
token, the gap remainder, the 41st refusal and its prescription, the stop marker a 429 writes, the
prune that keeps a long retry-after alive, and the five write transports that call both halves.

A real post-stamped.mjs --dry-run, unchanged

Run against objectstack-ai/objectstack#19572 with a body carrying one quoted reading:

$ node scripts/pm/post-stamped.mjs --repo=objectstack-ai/objectstack --comment=19572 \
    --file=dryrun-body.md --dry-run
:: exit 0

post-stamped: DRY RUN — nothing was written. Substituted with `2026-09-21T09:44Z` — 0 {{NOW}},
1 quoted · verbatim: 0 opener(s) inside a quoted span, left exactly as written (none). Stamp
verdict: stamped-by-declaration — nothing was substituted and a `{{WAS:…}}` rendered from its own
declaration, so the artefact is stamped as a reading of something else. Target would be
objectstack-ai/objectstack#19572 (comment).

Two readings, because "unchanged" deserves better than a glance:

  1. Against the pre-change tree. The same command run from a detached worktree at the merge base
    b3615f1a4c produces stdout and stderr byte-identical to the run above, with only the clock
    stamp normalised.
  2. A positive control, which is the stronger half. Re-running the dry run under
    OS_PM_WRITE_HOURLY_MAX=0 OS_PM_WRITE_MIN_GAP_MS=600000 — settings that would refuse ANY paced
    write at exit 10, or hang it for ten minutes — gives exit 0 and byte-identical stdout and
    stderr
    , and OS_PM_WRITE_PACE_FILE is never created. A dry run that had reached the
    throttle could not produce that result. Structural exclusion, measured.

Gates

Derived on this branch's own diff, exit codes captured before any pipe:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 68 command(s), 8 path(s) vs merge base b3615f1a4c, 1082 changed lines
node scripts/pm/dispatch-gates.mjs --ran ran.list          :: exit 0
  -> 68 derived famil(ies) accounted for — 63 run, 5 NOT-MEASURED (5 DERIVED from a
     recorded exit 3), 0 UNRUN.

63 of 68 green. The other five all answered PREREQUISITE NOT MET at exit 3 — NOT MEASURED,
neither a pass nor a finding — because each reads a built dist/ closure this container has not
built: check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure,
check:sourcemap-no-sources-content, check:type-check-debt. This diff contains zero paths
under packages/**, so none of the five has an input this branch can move; CI builds the closure
and runs all five.

check:pm-dispatch-gates was run detached per its own header (the battery took 831.4 s on this box,
past the container's foreground cap): ✓ dispatch-gates self-test: 1883 cases pass. :: exit 0.

Also run, named in the dispatch and outside the derived 68: pnpm check:pm-settings-deny-roster
(exit 0) and pnpm check:pm-governed-prose (exit 0). The five wired tools' own self-tests are in
the 68 and all green — post-stamped 544 cases, close-cards 102, sweep-stale-finding 91,
sweep-closed-cards 87, label-write 75.

Fourteen further families "apply once this card's changeset exists" — see the next section.

Landing tier

$ node scripts/pm/check-governed-merges.mjs --branch claude/issue-19572-write-pace
:: exit 0

derived from `git diff --name-only --no-renames b3615f1a4c 63f55e38c7` (three-dot): 8 path(s).
    origin/main = b3615f1a4c, claude/issue-19572-write-pace = 63f55e38c7, merge-base = b3615f1a4c.
    size: +1082 / -0 over 8 file(s) (0 binary, counted 0) — `git diff --numstat --no-renames` on the same range.
governed-surface predicate: 0 of 8 path(s) hit the register (6 surfaces, repo-agnostic).
  ✅  NOT governed — ordinary queue landing applies to a PR with exactly this file list.
      Derived from GOVERNED_SURFACES, not recalled. Re-run on the FINAL file list: the register
      has grown several times in two days, and a reading taken earlier in the session is recall.
  ⚠️  head repo: NOT MEASURED — this verdict cannot tell a fork PR from an internal one; `--pr PR-NUMBER` reads it.
  size: 1082 changed line(s) (+1082 / -0) ≤ 5000 — under the human-merge threshold (generated files included in the count).

(Verbatim, with one exception: the tool prints its --pr placeholder in angle brackets, respelled here
as PR-NUMBER because this platform's body sanitizer eats short angle-bracket fragments, fenced or not.)

⚠️ The card says Tier S; the register says NOT governed. #19572's body reads
"scripts/pm/**, Tier S — the director seat lands it after an at-tier read", but GOVERNED_SURFACES
does not list scripts/pm/**, and AGENTS.md Prime Directive #14 makes that table — not a card — the
authority ("adding a surface is an edit there"). This is reported, not acted on in either
direction: no seat here arms anything, and nothing was added to the register on this branch. The
owning seat decides whether the discrepancy is a card that overstated the tier or a register that is
missing a row.

Changeset

skip-changeset. The diff is scripts/pm/**, .github/workflows/lint.yml and one root
package.json script alias — nothing under packages/**, so no released package's files[]
ships one byte of it and there is nothing to publish.

维护者速读(草稿)

改了什么。 给 PM 的五个写工具装了一道共享节流阀:每次 POST/PATCH/PUT/DELETE 发出之前先问它
"现在能写吗",答完再把平台的回答喂回去。同一个 token 两次写之间至少隔 3 秒(不够就补睡),一小时
最多 40 次,第 41 次直接拒绝并打印处方;平台一旦发出 429 或二级限流信号,30 分钟内(或它要求的更长
时间)这个 token 的写全部被拒。

为什么改。 维护者 2026-09-21 的裁决「截流应该要加」。当天有两个席位账号在批量操作 issue 时被
平台停用。此前五个工具里没有任何一个会计数或限速——只有 label-write.mjs 会在事后把 429 归类。

风险与代价(含回滚)。 最大的代价是:预算用尽时席位会被硬拒(退出码 10),而不是等。这是刻意的
——在写路径里睡一小时是带借口的挂起。回滚成本很低:这是一次纯增量,五个工具各只多了两行调用和一个
import,git revert 即可;真要临时放宽,OS_PM_WRITE_HOURLY_MAX 可以在不改代码的情况下抬高(该
开关声明为"仅供测试",抬它属于维护者的行为,不是席位的)。锁是 fail-open 的,拿不到锁绝不会挡住
写入,只会让计数不那么精确。

席位意见。 (待席位填写)

你要做的。 只有一件:确认 40 次/小时与 3 秒这两个数字是你想要的。它们现在是合同(env 覆盖只对
测试成立),改它们要动代码。另外请看上面「Landing tier」一节标出的卡片与登记表不一致。


🤖 Generated with Claude Code

https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE


Generated by Claude Code

os-project-manager and others added 2 commits September 21, 2026 09:33
The helper only, ahead of wiring the five write transports: the per-token
SHA-256-prefix log, the 3 s gap, the 40/hour budget whose 41st is refused with
a prescription, and the stop marker a platform back-off signal writes.

Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE
Co-Authored-By: Claude <noreply@anthropic.com>
…te transports

Each tool's own `rest`/`restLive` calls `paceWrite` before a POST/PATCH/PUT/DELETE
and `noteResponse` after the answer, reads untouched. `classifyHttp`'s verdict is
handed to the throttle where a caller already has it, never re-derived.

Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE
Co-Authored-By: Claude <noreply@anthropic.com>
@os-project-manager os-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 21, 2026 — with Claude
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file labels Sep 21, 2026
… is bounded by attempts

Three things the first draft got wrong, all found by its own new cases:

- an unwritable log threw out of the transport. `paceWrite` now REFUSES with the
  distinct exit code and a one-line remedy — a throttle that cannot record cannot
  count, so it cannot honestly allow the write either;
- `noteResponse` is the asymmetric half: it runs one line before the caller throws
  the platform's own refusal, so it never throws, and reports the consequence
  (the marker did not land) instead;
- the lock retry was bounded only by a deadline read from an injected clock. A
  recursive mkdir over a path that is a FILE answers EEXIST, the same code
  contention answers, so an unusable path read as a busy lock and spun forever
  under a clock that does not advance. Bounded by attempts as well, the directory
  mkdir moved out of the retry, and both pinned.

Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE
Co-Authored-By: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2875a316dc0e8457e53e2c6fced3a1bcf6ee1d5b

Card #19572 (maintainer 「截流应该要加」), PR #19584, ruling #208 R5 shape: the diff and the card were read, the head's check-runs taken by REST, no derived-gate sweep. Fresh worktree at origin/main = 2306a75ecb, head checked out detached for every local run; self-tests and drives run at 2026-09-21T10:31Z, the positive control at 2026-09-21T10:34Z, the tier reading at 2026-09-21T10:35Z, the check-runs at 2026-09-21T10:38Z. Record written 2026-09-21T10:41Z.

① Derived judgments

1. Scope — RIGHT. git diff origin/main...2875a316 --name-status = exactly the eight files (A scripts/pm/write-pace.mjs; M the five write tools, package.json, .github/workflows/lint.yml); +1160 / −0 over three commits (5fc9ab474d, 63f55e38c7, 2875a316dc); nothing under packages/**. The lint.yml hunk is one step, Write-pace throttle self-test → pnpm check:pm-write-pace, inside the lint job (name: Lint & Repo Gates, line 179), placed among the other check:pm-* self-test steps (post-stamped, label-write, close-cards, the two sweeps), and unconditional — no if: between lint.yml lines 1091 and 1823, so it is outside every select-gate-families scope and its selftest was not needed. On check:self-test-wired: that gate's rule is 「every script CI runs that ships a --self-test must have that self-test run by CI too」, collected from workflow run: lines and pnpm check:* chains; CI runs write-pace.mjs only through this very step, so the step is not a mechanical consequence of that gate — it is the card's own item 4 (--self-test) made CI-visible in the roster's shape, and it needs no separate R6 sentence: the throttle IS the instrument the maintainer's sentence authorised, and this step is that instrument's self-test, not a second instrument.

2. The helper's contract — RIGHT at the defaults on every point; one WRONG under a test-only override (D1 below).

  • (a) gap: decidePace filters the log to the caller's key, sleepMs = last + minGapMs − now (a sibling's future reservation counts as last, so a second process paces behind a sleeping first), slept after the lock is released, one gapText line to stderr. Driven by the self-test (1 s later sleeps 2 s, not 3; per token). RIGHT.
  • (b) budget: trailing 60 min, inWindow.length ≥ hourlyMax → exhausted, exit(10), no sleep, prescription naming the count, the reopen instant (min(inWindow) + 60 min) and the fold advice; a refused write reserves nothing. Driven at budget 1: the 2nd write → exhausted, exits [10], slept [], text names 「1 write(s) … budget (1) … number 2 … reopens at (one hour after the injected clock)」. RIGHT at the defaults. ⛔ WRONG at OS_PM_WRITE_HOURLY_MAX=0 — see D1.
  • (c) stop marker: stopSignalFrom fires on 429; on 403 whose body matches secondary rate limit or abuse detection; on any retry-after (seconds or HTTP-date); on x-ratelimit-remaining: 0 at ANY status; and on verdict === 'ratelimit' handed in by value. Duration max(30 min, retry-after). Driven: 200 + remaining 0 → 30 min marker; 503 + retry-after HTTP-date 3 h → 180 min; a secondary-limit 403 with remaining 4999 and verdict refusal → marker recorded, the next write → stopped, exit 10, the expiry printed (stoppedText carries until STAMP). RIGHT.
  • (d) key: sha256(token.trim()).hex.slice(0, 12); an empty token keys to no-token (still paced). My own drive with a fixture token: the log holds {"t":…,"k":"87f62e9ce4ad",…} and the marker line; grep -rl of the token value over every file the drive wrote hits only the drive script itself, never the log. --status prints the key, never the token. RIGHT.
  • (e) env: the only reads are OS_PM_WRITE_PACE_FILE (paceFilePath), OS_PM_WRITE_MIN_GAP_MS and OS_PM_WRITE_HOURLY_MAX (limitsFrom); --status reads GITHUB_TOKEN/GH_TOKEN for the identity, which is the token, not an override. A malformed override falls back to the default and says so. RIGHT.
  • (f) the three fail-closed fixes in 2875a316dc: (i) an unwritable log — withPaceLock swallows its own parent-mkdir failure, the poll breaks at once on a non-EEXIST error, fn() throws from writeRecordsTo, paceWrite catches → unrecordableText naming the path and the OS_PM_WRITE_PACE_FILE remedy, exit(10); driven under a REAL poll sleep: returns unrecordable, exits [10], 0 ms. (ii) noteResponse wraps the whole lock in try/catch, logs the consequence (「the NEXT write … will NOT be refused」) and returns recorded: false; the self-test drives it on the same unwritable path. (iii) the lock loop is attempt below 400 AND now() below deadline; a held lock under a frozen clock with a real 40 ms poll returns after at most 400 × 40 ms = 16 s and fails open; an unusable path breaks the loop on the first non-EEXIST. Neither a frozen clock nor an unusable path can hang. RIGHT.

3. Wiring — RIGHT; one WRONG in the prescription's prose (D2 below). Each of the five tools has exactly ONE fetch( (post-stamped 2548, label-write 338, close-cards 448, sweep-closed-cards 503, sweep-stale-finding 361); each computes paced = isWriteMethod(method), awaits paceWrite before the fetch and calls noteResponse after it (both the refusal path and the 2xx path in the three that throw on !res.ok; the one shared exit in label-write and close-cards, which never throw on status), so GET is never paced. The write-verb census over scripts/pm/*.mjs is twelve sites in exactly those five files; no other .mjs under scripts/pm issues a write verb and none spawns gh/curl; the self-test's last battery pins that roster against git ls-files. Dry runs: post-stamped returns from if (options.dryRun) before writeArtefact (its only in-dry-run REST call is a GET probe); label-write's dry run returns before its plan runs; the sweeps act only under --write. Positive control, measured: OS_PM_WRITE_HOURLY_MAX=0 OS_PM_WRITE_MIN_GAP_MS=600000 node scripts/pm/post-stamped.mjs --dry-run --comment=1 --file=BODY --repo=objectstack-ai/objectstack → exit 0, stdout and stderr byte-identical to the un-overridden run after stamp normalisation, and no pace file created. Label-write: each call in its step-③ plan (label POST, label DELETE per removal, assignee POST, assignee DELETE), the whole-set PATCH fallback and the step-④ re-add POST each go through rest(), so each is counted and gapped. The director's question: between the label POST and the read-back GET there is no paced call, so the refusal cannot land in exactly that gap — the read-back is a GET and always runs after a POST that returned. It CAN land between two step-③ calls (label POST landed, then the label DELETE refused — a two-state card), before the fallback PATCH (nothing landed there: the additive verb was refused), and before the re-add POST after a strip. What the tool prints then: its ③ 写 — POST … HTTP 200 (ok) line for the call that landed, then the throttle's refusal block, and the process exits 10 from inside rest() — the read-back does NOT run, no ④ line prints, and label-write's own 「The board may be PARTIALLY written — the calls above say which landed」 never prints. Close-cards: the comment goes through a post-stamped CHILD, whose exit 10 close-cards already reports honestly (「comment REFUSED — post-stamped exit 10 … NOTHING was written … Stopping」); its closing PATCH is in-process, so a refusal there leaves the comment landed and the card open, with halfWriteText bypassed by the exit.

4. classifyHttp reuse — RIGHT. write-pace.mjs imports nothing from label-write.mjs; stopSignalFrom takes verdict by value and its own arms are the four signals classifyHttp does not answer (a 429 with quota left, a 403 whose BODY names the secondary limit, a bare retry-after, remaining 0 on any status). The overlap on a 429/403 with remaining 0 yields the same marker either way, not a second classification. The dev's finding is confirmed by reading classifyHttp (line 311: ratelimit only when rateRemaining === 0, else line 313 refusal) and by the drive in 2(c): noteResponse runs inside rest() before the verdict is returned to runLabelWrite, so the marker is on disk before the fallback doCall('card-patch', …) calls paceWrite, which refuses it at exit 10. Reliable as long as the log is writable — the only gap is a log that became unwritable between the POST and the PATCH, in which case the earlier paceWrite would already have refused. Post-stamped hands platformRefusalText(text) as the body, which keeps the JSON message (where the platform puts the sentence); the sweeps hand the raw text; label-write and close-cards hand the parsed object, which namesSecondaryLimit stringifies — and a retry-after on the same answer marks it regardless.

5. Self-tests — RIGHT. On head 2875a316dc: write-pace --self-test exit 0, 「69 cases pass across 9 batteries」; post-stamped exit 0 (544 cases / 18 batteries); label-write exit 0 (75 / 9); close-cards exit 0 (102 / 11); sweep-closed-cards exit 0 (87 / 9); sweep-stale-finding exit 0 (91 / 9). The case names cover the 41st write (「the 41st is refused」, 「…with the distinct exit code」, 「…and it did NOT sleep instead」, 「a refused write reserves NOTHING」), the unwritable log (「a log it cannot write REFUSES the write」), the frozen-clock lock (「a held lock under a FROZEN clock still returns」), the marker from a 429, the prune battery (expired marker dropped, two-hour marker kept, torn line dropped, file rewritten pruned), and 「THE case: the token never appears in the file, in any field」. Floor is a battery-name roster (9 batteries, per-battery minimums) with the selfTestReachedVerdict handshake at the dispatch. --status prints seven labelled lines to stdout (file, token key, budget N of 40 · M left, gap, marker, verdict, the READ notice), exit 0, and creates no file.

Defects found (neither reaches the contract at its defaults):

  • D1 — WRONG, test-only path. OS_PM_WRITE_HOURLY_MAX=0 on a LIVE write: decidePace returns exhausted with resumeAtMs = Math.min() + WINDOW_MS = Infinity, and exhaustedText → stampUtc(Infinity) throws RangeError: Invalid time value out of paceWrite, out of the transport, before any refusal line and before exit(10). Driven: 「THREW RangeError Invalid time value, exits before throw [], 0 lines logged」. Reachable only when hourlyMax is 0 (the sole way inWindow is empty at exhausted), so the defaults are untouched; but the PR body describes exactly these settings as 「would refuse ANY paced write at exit 10」, which is not what they do. The positive control's evidence survives (exit 0, identical output, no file — a crash would have been louder still). One-line fix in decidePace (inWindow.length ? Math.min(...inWindow) + WINDOW_MS : nowMs) plus a case beside 「zero is a real value」.
  • D2 — WRONG in the prescription's prose; a reporting gap on mid-run refusals. exhaustedText says 「nothing was written, nothing is half-written, and this process will not retry」 and the header adds 「re-running after the printed instant is safe」. Because the refusal is a process.exit(10) from INSIDE the transport, in a run that already landed a write (label-write's plan and re-add, close-cards' PATCH after its comment, sweep-closed-cards' POST after its DELETEs) those sentences are false for that run, and the tools' own half-state reporters never print (item 3). Close-cards re-run after a mid-card refusal re-posts the closing comment. Follow-up: narrow the sentence to this write, and let the tools route on a typed refusal so their reporters print before the exit — its own card, not a rider.

Nits, not blocking: (N1) the frozen-clock lock case compares the injected T0 (a fixed 2026-09-21 10:00 UTC constant in the source) with the fixture lock's REAL mtime, so it takes the attempt-bound path only because the wall clock is now after T0; a clock before 09:59:45Z that day steals the lock and fails 「never stolen」 (my drive at an injected 12:00Z stole it in 40 ms) — pin the fixture's mtime with utimesSync. (N2) the PR body is stale against the head: 64 cases, +1082 and head 63f55e38c7 in its Landing-tier block vs 69, +1160 and 2875a316dc; the card's os-dev-report carries the current numbers. (N3) when the lock fails open, the whole-file rewrite can drop a freshly written STOP MARKER as well as undercount a write; the header names only the undercount.

② Semver level

Clause-②: no — carriers: none hung. Nothing under packages/**; skip-changeset is on the PR; no changeset, correctly.

③ Boundary flags

  • Check-runs on 2875a316dc at the reading: Lint & Repo Gates success (id 106293792677), TypeScript Type Check success, Test Core success, Dogfood Regression Gate success, Build Core success, Temporal Conformance (live PG + MySQL) success, Governed Surface Queue Guard success — 7 of 7 required contexts completed / success; none in progress at that reading.
  • Labels on the PR: ci/cd, size/xl, dependencies, skip-changeset. Draft, no auto-merge, no comments before this one.
  • Tier: node scripts/pm/check-governed-merges.mjs --branch claude/issue-19572-write-pace → exit 0, 「governed-surface predicate: 0 of 8 path(s) hit the register (6 surfaces)」, 「NOT governed — ordinary queue landing applies」, size 1160 ≤ 5000. The card's 「Tier S」 line and the register disagree; the register is the authority (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14) and that question is not this PR's — the director lands it through the ordinary queue on green after this record.
  • Writes by this review: this one comment, through the origin/main copy of post-stamped.mjs from a worktree at 2306a75ecb. No MCP GitHub tool, no label, no ready flip, no auto-merge, no body edit, no issue.

Implemented-by: claude/issue-19572-write-pace
Reviewed-by: session_012GcsUbuqFGBibkEDMRC1eE

VERDICT: PASS — the contract holds at its defaults on every item, driven and read; D1 and D2 are named follow-ups for the director's landing decision (D1 is a one-line fix whose new head would need a fresh record; D2 is its own card).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — director seat, summon #25 (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-21T10:45Z

  • Contract review of record: 5759177018, Served-tier: CONTRACT_REVIEW_TIER, judged head 2875a316dc0e8457e53e2c6fced3a1bcf6ee1d5b (the current head), VERDICT: PASS. The reviewer's transcript re-read by this seat: every request stamped at the tier constant, zero fallbacks. Independence pair Implemented-by: claude/issue-19572-write-pace (the dev was this seat's mode:subagent, direct dispatch under the maintainer's 「截流应该要加」) / Reviewed-by: session_012GcsUbuqFGBibkEDMRC1eE (an isolated reviewer subagent whose verdict this seat adopts verbatim).
  • What lands (R5 shape review: diff + the head's check-runs, no local gate sweep): scripts/pm/write-pace.mjs — a 3 s minimum gap that is slept, a trailing 40-per-hour budget whose 41st write is refused at exit 10 with a prescription and never a silent wait, a 30-minute stop marker (or retry-after when longer) from a 429, a secondary-rate-limit 403, a bare retry-after, or x-ratelimit-remaining: 0, a per-token log keyed by a 12-hex SHA-256 prefix with the token never in the file, env overrides for tests only, --self-test and --status; wired before and after the one transport of post-stamped.mjs, label-write.mjs, close-cards.mjs, sweep-closed-cards.mjs and sweep-stale-finding.mjs, guarded by a write-method predicate so reads never pace and dry runs issue no write; check:pm-write-pace in package.json and its lint.yml step (the self-test-wired rule's own consequence). Self-tests at the head all exit 0 (write-pace 69 cases / 9 batteries; the five tools 544, 75, 102, 87, 91). Seven required contexts success on the head; skip-changeset on the PR.
  • Carriers: none hung (Clause-②: no). Tier: check-governed-merges.mjs --branch answers NOT governed — scripts/pm/** is not on the register — so this seat lands it through the ordinary queue; whether scripts/pm/** joins the register is the maintainer's open word, not this PR's.
  • Two readings the reviewer named as follow-ups, neither reaching the card's contract at its defaults, carried here rather than into a fix round (a fix commit would move the head and cost a fresh record): (D1) OS_PM_WRITE_HOURLY_MAX=0 on a live write throws RangeError instead of refusing at exit 10 — test-only override, one-line guard in decidePace; (D2) the refusal prose 「nothing was written, nothing is half-written」 is false when the 41st write lands mid-run after earlier writes, because process.exit(10) inside the transport bypasses the tools' own half-state reporters. Whether D2 becomes a card is put to the maintainer under the filing door (class ①); D1 rides whichever PR next touches write-pace.mjs.
  • Landing: not governed — this seat flips ready and enqueues on the seven green contexts after this record. After merge: landing record on Pace every seat write — one shared throttle in front of the five PM write scripts (maintainer 「截流应该要加」) #19572 and close.

Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Pace every seat write — one shared throttle in front of the five PM write scripts (maintainer 「截流应该要加」)

1 participant