Repository navigation
Commit 31184e5
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 `#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
#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 b3615f1, 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 b3615f1 63f55e3` (three-dot): 8 path(s).
origin/main = b3615f1, claude/issue-19572-write-pace = 63f55e3, merge-base = b3615f1.
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.com/claude-code)
https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE
---
_Generated by [Claude
Code](https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37ed9ae commit 31184e5
8 files changed
Lines changed: 1160 additions & 0 deletions
File tree
- .github/workflows
- scripts/pm
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1336 | 1336 | | |
1337 | 1337 | | |
1338 | 1338 | | |
| 1339 | + | |
| 1340 | + | |
| 1341 | + | |
| 1342 | + | |
| 1343 | + | |
| 1344 | + | |
| 1345 | + | |
| 1346 | + | |
| 1347 | + | |
| 1348 | + | |
| 1349 | + | |
| 1350 | + | |
| 1351 | + | |
| 1352 | + | |
| 1353 | + | |
| 1354 | + | |
| 1355 | + | |
| 1356 | + | |
| 1357 | + | |
| 1358 | + | |
| 1359 | + | |
| 1360 | + | |
| 1361 | + | |
| 1362 | + | |
| 1363 | + | |
1339 | 1364 | | |
1340 | 1365 | | |
1341 | 1366 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
| 90 | + | |
90 | 91 | | |
91 | 92 | | |
92 | 93 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
144 | 145 | | |
145 | 146 | | |
146 | 147 | | |
| |||
437 | 438 | | |
438 | 439 | | |
439 | 440 | | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
440 | 446 | | |
441 | 447 | | |
442 | 448 | | |
| |||
460 | 466 | | |
461 | 467 | | |
462 | 468 | | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
463 | 479 | | |
464 | 480 | | |
465 | 481 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| 106 | + | |
106 | 107 | | |
107 | 108 | | |
108 | 109 | | |
| |||
327 | 328 | | |
328 | 329 | | |
329 | 330 | | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
330 | 336 | | |
331 | 337 | | |
332 | 338 | | |
| |||
350 | 356 | | |
351 | 357 | | |
352 | 358 | | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
353 | 371 | | |
354 | 372 | | |
355 | 373 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
770 | 770 | | |
771 | 771 | | |
772 | 772 | | |
| 773 | + | |
773 | 774 | | |
774 | 775 | | |
775 | 776 | | |
| |||
2539 | 2540 | | |
2540 | 2541 | | |
2541 | 2542 | | |
| 2543 | + | |
| 2544 | + | |
| 2545 | + | |
| 2546 | + | |
| 2547 | + | |
2542 | 2548 | | |
2543 | 2549 | | |
2544 | 2550 | | |
| |||
2554 | 2560 | | |
2555 | 2561 | | |
2556 | 2562 | | |
| 2563 | + | |
| 2564 | + | |
| 2565 | + | |
2557 | 2566 | | |
2558 | 2567 | | |
2559 | 2568 | | |
2560 | 2569 | | |
2561 | 2570 | | |
| 2571 | + | |
| 2572 | + | |
| 2573 | + | |
2562 | 2574 | | |
2563 | 2575 | | |
2564 | 2576 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
153 | 153 | | |
154 | 154 | | |
155 | 155 | | |
| 156 | + | |
156 | 157 | | |
157 | 158 | | |
158 | 159 | | |
| |||
495 | 496 | | |
496 | 497 | | |
497 | 498 | | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
498 | 503 | | |
499 | 504 | | |
500 | 505 | | |
| |||
505 | 510 | | |
506 | 511 | | |
507 | 512 | | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
508 | 518 | | |
509 | 519 | | |
510 | 520 | | |
511 | 521 | | |
| 522 | + | |
512 | 523 | | |
513 | 524 | | |
514 | 525 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
| 174 | + | |
174 | 175 | | |
175 | 176 | | |
176 | 177 | | |
| |||
353 | 354 | | |
354 | 355 | | |
355 | 356 | | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
356 | 361 | | |
357 | 362 | | |
358 | 363 | | |
| |||
363 | 368 | | |
364 | 369 | | |
365 | 370 | | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
366 | 375 | | |
367 | 376 | | |
368 | 377 | | |
369 | 378 | | |
370 | 379 | | |
371 | 380 | | |
| 381 | + | |
372 | 382 | | |
373 | 383 | | |
374 | 384 | | |
| |||
0 commit comments