Repository navigation
feat(pm): pace every seat write — one shared throttle in front of the five write scripts (#19572) - #19584
Conversation
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>
… 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>
Contract reviewServed-tier: 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 ① Derived judgments1. Scope — RIGHT. 2. The helper's contract — RIGHT at the defaults on every point; one WRONG under a test-only override (D1 below).
3. Wiring — RIGHT; one WRONG in the prescription's prose (D2 below). Each of the five tools has exactly ONE 4. 5. Self-tests — RIGHT. On head Defects found (neither reaches the contract at its defaults):
Nits, not blocking: (N1) the frozen-clock lock case compares the injected ② Semver levelClause-②: no — carriers: none hung. Nothing under ③ Boundary flags
Implemented-by: 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 |
|
Provenance — director seat, summon #25 (
Generated by Claude Code |
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 nobody, 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:
10, with a prescription. ⛔ Never a silent waitretry-afterwhen that reaches furtherThe log is
~/.cache/objectstack-pm/write-pace.jsonl(OS_PM_WRITE_PACE_FILEmoves it), one JSONline per write, keyed by
tokenKey()— a 12-hex prefix of the token's SHA-256. ⛔ The token itselfnever reaches the file, in any field.
OS_PM_WRITE_MIN_GAP_MSandOS_PM_WRITE_HOURLY_MAXoverride① 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
10is distinct from every code the five calling tools use (their highest is 9), so a seatreading an exit code can always tell "the throttle stopped me" from anything the tool itself decided.
Four design points worth reading before the diff
classifyHttpis reused by VALUE, never re-derived.label-write.mjsalready owns "what doesthis status mean", and its
ratelimitarm is handed tonoteResponseasverdictby the twocallers 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, andx-ratelimit-remaining: 0on ANY status including the 200 that spent the last unit.mkdirlock, and thesleep 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.
guard is
isWriteMethod()in each transport, and every--dry-runpath in those tools returnsbefore it issues a write request at all. Proven below with a positive control, not asserted.
post-stamped.mjsprints the rendered body to stdout and--jsonafter it; a pacing line on stdout would corrupt both.
--statusis the one command whose reportis 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/pmfinds twelve write sites in fivefiles. The wiring is per transport, not per site, so all twelve are covered by five pairs:
paceWritebeforenoteResponseafterpost-stamped.mjsrestPOST(2586), bodyPATCH(2590)label-write.mjsrestPOST/DELETE, assigneePOST/DELETE, the whole-setPATCHfallback, the re-addPOST(538–541, 585, 626)classifyHttp's verdict handed overclose-cards.mjsrestPATCH(651)classifyHttp's verdict handed oversweep-closed-cards.mjsrestDELETEper residue label (622), one commentPOST(633)sweep-stale-finding.mjsrestLiveDELETEthat strips the stale grade (424)Line numbers are the post-merge ones.
close-cards.mjsposts its comment by spawningpost-stamped.mjsas a child, so that write is paced by post-stamped's own transport rather thantwice.
ensure-pm-labels.shruns throughghunder the maintainer's hand and stays out, as thecard 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, injectedexit; nothing waits three real seconds and nothing touches the real cache file. Floor is the battery
ROSTER, not a total, and the verdict sets
selfTestReachedVerdictas its last statement.GET/HEAD/omitted, and that case is not a way to dodge it4Ofalls back to 40 AND says so;-1is malformed;0is a real valuex-ratelimit-remaining: 0on a 200,classifyHttp's verdict as an input; ⛔ a plain 403 writes none; a longretry-afterwins and a short one does not shorten; refused while live, allowed once expired--statuscounts the window, names the marker, never prints the token and spends no budget; the CLI exits 0, and a typo is usageisWriteMethod; and the roster IS the set of files underscripts/pmthat issue a write verb, so a SIXTH write path cannot land unpacedThat 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.
A real
post-stamped.mjs --dry-run, unchangedRun against
objectstack-ai/objectstack#19572with a body carrying one quoted reading:Two readings, because "unchanged" deserves better than a glance:
b3615f1a4cproduces stdout and stderr byte-identical to the run above, with only the clockstamp normalised.
OS_PM_WRITE_HOURLY_MAX=0 OS_PM_WRITE_MIN_GAP_MS=600000— settings that would refuse ANY pacedwrite at exit 10, or hang it for ten minutes — gives exit 0 and byte-identical stdout and
stderr, and
OS_PM_WRITE_PACE_FILEis never created. A dry run that had reached thethrottle could not produce that result. Structural exclusion, measured.
Gates
Derived on this branch's own diff, exit codes captured before any pipe:
63 of 68 green. The other five all answered
PREREQUISITE NOT METat exit 3 — NOT MEASURED,neither a pass nor a finding — because each reads a built
dist/closure this container has notbuilt:
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 pathsunder
packages/**, so none of the five has an input this branch can move; CI builds the closureand runs all five.
check:pm-dispatch-gateswas 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 inthe 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
(Verbatim, with one exception: the tool prints its
--prplaceholder in angle brackets, respelled hereas
PR-NUMBERbecause this platform's body sanitizer eats short angle-bracket fragments, fenced or not.)"
scripts/pm/**, Tier S — the director seat lands it after an at-tier read", butGOVERNED_SURFACESdoes not list
scripts/pm/**, and AGENTS.md Prime Directive #14 makes that table — not a card — theauthority ("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 isscripts/pm/**,.github/workflows/lint.ymland one rootpackage.jsonscript alias — nothing underpackages/**, so no released package'sfiles[]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