fix(spec): close the shared rate-limit budget — one declaration was answering two doors, and one of them dropped the key in silence - #18861
Conversation
…ers one door
`ServerRateLimitConfigSchema` was `strictObject({ … guidance: { keyBy, store } },
RateLimitConfigSchema.shape)` — built from the OPEN schema's own shape object, so
one declaration answered for two emitted defs with opposite doors.
`system/ServerRateLimitConfig` refused an undeclared `keyBy` with its
prescription; `shared/RateLimitConfig`, mounted bare on `apis[].rateLimit`,
accepted the same key and dropped it in silence, and both `guidance` entries
prescribed to nobody there. A misspelled budget was the same story one key over:
`windowSeconds: 60` parsed green and metered the 60000 ms default.
The strictness and the tables move to the shared schema, where both defs inherit
them; the server schema keeps only what is genuinely server-only, its two bounds
checks. That leaves ONE declaration, so the gate's declaration match still
resolves to exactly one and the closed twin's verdict is untouched.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
…the strictness ledger The `#18301` DOOR pin used `shared/RateLimitConfig` as a LIVE open def sharing a closed declaration's shape. Closing that def is what this branch does, so the fixture's own guard fired with its own prescription — "re-pick the pair". It is re-picked: the rate-limit twins become the DARK leg (one declaration, two defs, and now one door — so re-opening the shared shape turns this test red), and the LIT leg moves to `ui/ViewItem:confg`, a def whose declaration names the key and whose delivery the probe's one-key document cannot reach past the discriminator. Measured, not assumed: the same document written whole DOES raise the prescription, and both halves are guarded loudly. The `shared/` ledger row is annotated for the fourth instance of a shape it has now recorded three times — a directory verdict that was right for the directory and wrong for one file in it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
…telimit-open-twin-guidance
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0bafb0382fadbdbffb2e35e0fbc028b6c6dc83f && git checkout a0bafb0382fadbdbffb2e35e0fbc028b6c6dc83f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin be7aeb82756d054af451c7b248686d878b311210 35b84505d2ac16ee31a2b841119088d186b0b959 && git checkout -B drift-repro be7aeb82756d054af451c7b248686d878b311210 && git merge --no-ff 35b84505d2ac16ee31a2b841119088d186b0b959
node scripts/docs-audit/affected-docs.mjs --json be7aeb82756d054af451c7b248686d878b311210
|
… the direction it moves The declaration was filed `yes (widening)` under "when unsure, declare yes", before the shape was chosen. The measurement went the other way: no key is added to a published payload here, and the accept set shrinks — a refusal replaces a silent accept. The arm is the one line a consumer reads for direction of change, so it says so. Nothing else in this changeset moves. The BREAKING banner and the ADR-0087 `not-required (no-migration-prescription)` disposition both stay: the breaking-ness is carried by those, not by the arm, and the gate reads the same verdict off either signal. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
条款② 收口:申报已翻成
|
| 载体 | 现值 | 谁读它 |
|---|---|---|
卡 #18578 的认领(5723662465) |
✅ no (narrowing) |
声明肢判自这里 |
changeset .changeset/rate-limit-budget-unknown-keys-refused.md:10 |
✅ no (narrowing) |
check-changeset-no-major 读这一行 |
| PR 正文 | yes (widening) |
⛔ 没有门禁读它 |
⇒ node scripts/pm/check-clause2-carriers.mjs --pair 18861 现读 exit 0:「both carriers agree … no widening tell」。
⛔ 本席不改 PR 正文那一行,理由是章程自己的话:「Before rewriting a PR body over it, check whether any gate actually reads that line — the changeset gates read the line in the changeset, not in the PR body.」而该检查器也自标该行「stated as an INPUT only」。
载体是「从未适用」,⛔ 不是「已清」
needs:contract-review 原挂在卡 #18578 上(PR 上未挂)。申报落到 no 之后,该闸对本卡从一开始就不适用 ⇒ 本席整对摘除。
yes,走了隔离达档复核、拿到 PASS、留了同形记录(5723255433)、再剥双载体。两者的证据形状不同,⛔ 不可互相类比。
dev 的报告评论保持原样
5723605967 的 JSON 里仍写着派发时的 yes (widening)。⛔ 本席不改它 —— 那是一份当时为真的记录,改掉等于把历史修成没发生过。当前值以上表三行为准。
⭐ 一条收获,记给下一个人
dev 指出:门禁现在把 narrowing 这个臂本身也当作破坏性信号,而此前只有 BREAKING 横幅承担这件事。⇒ ADR-0087 的处置标记现在被两个独立信号各自要求,而它同时满足两个。这是加强,⛔ 不是判决改变 —— 本席复读了 changeset 的第 7 行(BREAKING 横幅)与第 12 行(not-required (no-migration-prescription)),两者在这次单行改动中逐字节未动,check-adr-0087-registration --base origin/main 仍 exit 0。
Generated by Claude Code
… beside its population (objectstack-ai#18863) Fixes objectstack-ai#18579 Clause-②: no Two census figures in the proof-4 prose of `packages/spec/scripts/build-schemas.ts` label a population they do not measure. Both are corrected, and both now carry their population — and the tree they were read on — in the same sentence, which is what the triage note on the card asked of whoever took it. ## The two figures **(1) The docblock's not-delivered rationale.** It said *"on the shipped graph 7 of the 8 defs in that state are unions"*. Measured: that state holds **9 keys on 4 defs**, and **3 of those 4 defs** are unions, carrying **7 of the 9 keys**. The 7 was the KEY count wearing the DEF label. **(2) The closing line of the same docblock**, which sent its reader to PR objectstack-ai#18529's body *"for the census run"*. That body's census row labels *"defs resolving to exactly one declaration that names an undeclared key"* with **258** — but 258 is the count of defs resolving to exactly one declaration **whether or not it names anything**. The labelled population measures **147**. That row lives in a merged PR body and is not editable from here, so the docblock now records the census in the tree and tells its reader not to re-derive it from that body. The argument both figures support is unchanged — unions do dominate the not-delivered set (3 of 4 defs, 7 of 9 keys). Only the arithmetic moved. ## Re-measured on this head, not copied from the card The card's numbers were taken on branch `9e0324f807` and `packages/spec/src` has moved on `main` since, so nothing was copied. The whole census was re-derived with **this head's own verbatim `computeGuidanceRoutes`**: a byte-identical copy of `scripts/build-schemas.ts` (prefix proven equal by `git hash-object`: `0803422391…` on both) with a census block appended, run as the real generator so `zodByDefKey`, `generatedSchemas` and the declaration registry are the ones the gate itself sees. The copy was deleted before the first commit; it is in no diff. **Tree measured: `objectstack-ai/objectstack` at `88aa326deb`** — the merge base of this branch with `origin/main`. | population | count | |---|---| | emitted defs (`zodByDefKey`, = bundled `$defs`) | 1527 | | of those, emitted artifact carries `additionalProperties: false` | 1117 | | of those 1527, defs resolving to exactly one declaration (naming anything or not) | 258 | | of those 258, defs whose declaration NAMES a key the def does not declare | **147** | | keys those 147 defs are promised | 779 | | of those 779, keys the def delivers (`prescribed`) | 770 | | of those 779, keys not delivered (`declared-but-silent`) | **9** | | of those 9 keys, keys on `shared/RateLimitConfig` | 2 | | defs carrying a not-delivered key | **4** | | of those 4 defs, unions | **3** (`ui/ChartGroupBy`, `ui/ViewItem`, `ui/RecordHighlightsField`) | | of the 9 not-delivered keys, keys on those 3 union defs | **7** | | defs with an empty shape (excluded outright) | 6 | | declarations carrying an empty shape (what each of those 6 would answer to) | 9 | | defs whose match is ambiguous | 0 | | defs with no derivable shape | 304 | **What validates the instrument is the rest matching, not the conclusion agreeing.** Reproduced exactly against the card: 1117 · 258 · 147 · 779 · 770 · 9 · 2 · 6 · 9 · 0 ambiguous · 3-of-4 · 7-of-9. Drifted with `src`, as the card predicted: 1525 → **1527** emitted defs, 303 → **304** no-shape, 408 → **410** not-`additionalProperties`-false. Independent corroboration of the 1527/1538 pair from the generator's own summary line: `check:authorable-surface` prints `bundled schema: objectstack.json (1527 definitions)` / `Successfully generated 1538 schemas`. **Cross-check tying the partition to the gate's own function:** every one of the 779 promised (def, key) pairs the census derived was fed back through the head's verbatim `verdictFor` — **0** came back `none`. A replication that invented a pair the real lookup does not hold would show up there. ## Sequencing with PR objectstack-ai#18861 PR objectstack-ai#18861 (card objectstack-ai#18578) repairs the `shared/RateLimitConfig` open-twin defect and therefore moves this exact partition (its author measures delivered 770 → 772, not delivered 9 → 7). It is **absent from the tree measured here**, proven twice: `git merge-base --is-ancestor 36adeca HEAD` exits 1 on a non-shallow checkout with a lit control leg (`88aa326deb` → exit 0), and the census itself still lists `shared/RateLimitConfig:keyBy` and `:store` as not-delivered, which is precisely the defect that PR removes. So both replacement sentences are written as readings of a **named commit** (`at 88aa326 that state held …`), not as claims about whatever `main` holds today. They stay true when objectstack-ai#18861 lands; they become stale, visibly, with the commit that says so right there. The docblock also now tells its next reader that which defs sit in that state is a fact about the graph rather than a property of the proof — "closing an open door moves it" — and to re-measure rather than re-date. Nothing here waits on or depends on that branch. ## Not taken: the `delivers()` fixture The card's optional fourth item — a fixture for the `message`-includes leg, the one refusing a strict clone built without the declaration's error map — is **handed back, with a reason rather than a shrug**. Three routes exist and each is blocked by a rule this repo states out loud: 1. **A synthetic def on the shipped graph.** `packages/spec/src` is published (`files[]` carries `src/**/*.zod.ts`), so this ships a fake schema to consumers. 2. **A synthetic def in the test sandbox.** `build-schemas-check-mode.test.ts` builds each sandbox with `fs.symlinkSync` for `src` — its own comment says *"`src/` is the fixture's own, so the population a run observes is the repo's"*. A fixture def would have to be written into the real `src`, i.e. route 1. Copying `src` per sandbox instead is a structural change to a 4466-line harness. 3. **Exporting the leg for a unit test.** `delivers()` is a closure over module-level state, and that file's header rule is *no test-only seam*. So it is not a fixture-sized job; it is a restructuring, and the card is explicit that this PR should not become two things. Recommendation for whoever files it: route 2, as its own card, because it also unlocks fixtures for the other fail-closed legs. The census zero that defends the leg today is re-stated above with its tree, so the next reader can see what it rests on. ## Verification - `pnpm --filter @objectstack/spec build` — green (runs `gen:schema`, i.e. the edited generator, end to end) - `pnpm --filter @objectstack/spec typecheck` — green (all three legs: `tsc --noEmit`, `check:scripts-typecheck` which compiles the edited file, `check:test-typecheck`) - `pnpm --filter @objectstack/spec test` — 487 files / **14055 tests** passed - `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/build-schemas-check-mode.test.ts` — 1 file / **85 tests** passed. This is the suite that spawns the edited generator, and it is **not** in the package's `pnpm test`: that script runs `--project local` only, and vitest's filter guard says so out loud when you aim the wrong project at the file. - `scripts/file-description.test.ts` + `scripts/def-key-collisions.test.ts` (`--project repo`) — 128 passed; these are the only other tests that read this script by name. - **53 gate families** derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` — 51 green. Two exit **3**, which is that gate's own NOT-MEASURED code and neither a pass nor a failure: `check:dual-build-cjs-loads` and `check:lean-entry-closure` both need a whole-repo `pnpm build` that this worktree does not carry. Neither can move on a comment in an unpublished script; CI builds fresh. - `pnpm lint` equivalent run whole, not narrowed: `npx eslint . --no-inline-config --format json` at `8f9a42d8a2` — **6846 files, 0 errors, 0 warnings**. - Control-byte self-scan over the edited file (`grep -naP` over the C0 range plus DEL) — no match; `pnpm check:nul-bytes` green. ## No changeset (`skip-changeset`) Nothing published moves. Measured rather than asserted: `@objectstack/spec`'s `files[]` is `dist · json-schema · liveness · prompts · llms.txt · README.md · src/**/*.zod.ts · CHANGELOG.md · api-surface · spec-changes.json` — **no `scripts/` entry** — and grepping every shipped path for `computeGuidanceRoutes` returns **0 hits**, against a lit positive control (`keySetMatches`, which is found in `dist/shared/index.js` and four more). The diff is comment-only inside that unpublished file, so the blast radius is the gate's next reader, not an author and not a consumer. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18578
Clause-②: yes (widening)
ServerRateLimitConfigSchemawas declaredstrictObject({ … guidance: { keyBy, store } }, RateLimitConfigSchema.shape)— built from the OPEN schema's own shape object. One declaration therefore answered for two emitted defs with opposite doors:keyBysystem/ServerRateLimitConfigshared/RateLimitConfigz.objectThe two
guidanceentries prescribed to nobody on the open twin. And a misspelled budget was the same story one key over: on the bare mountwindowSeconds: 60parsed green and metered the 60000 ms default — a thousandfold miss on the one key whose job is to bound spend, reported as success.The census — the method, re-run on current
main⛔ Not re-derived by grep: the card records why a
CONTRACT_REVIEW_TIERsweep with live controls found nothing here (it hunted an OPEN clone built from a STRICT schema's shape; this is the STRICT one built from the OPEN one's shape, which every grep shape in that sweep is blind to by construction). The instrument is the gate's own —computeGuidanceRoutes()inpackages/spec/scripts/build-schemas.ts: match every emitted def to its declaration by sorted key set plus per-entry instance identity, then write the key at the def and read what comes back.Driven over every emitted def at
42f8df1723(currentmainwhen this branch was cut), and again on this branch:strictObjectdeclarations registeredThe filer's figures reproduce exactly (258 / 779 / 770 / 9); the def total moved 1525 → 1527 with the tree. One refinement worth recording: 258 is the count of defs resolving to exactly one declaration, and the subset whose declaration also names an undeclared key is 147 — the card's sentence folds the two.
The 9 not-delivered, itemised:
shared/RateLimitConfig:keyByand:store, the live members this card is about. Probe verdictaccepted-and-stripped.ui/ChartGroupBy:function,ui/ChartGroupBy:groupBy,ui/RecordHighlightsField:icon,ui/ViewItem:confg,ui/ViewItem:isPinned,ui/ViewItem:sortOrder,ui/ViewItem:columnState. An acknowledged probe boundary, ⛔ not a clean zero and ⛔ not a finding — and the mechanism is now measured rather than assumed. Each is a discriminated union; the probe writes{ [key]: null }and nothing else, so the DISCRIMINATOR is missing and the union answersinvalid_uniononviewKindbefore any arm's door is reached. Written whole, the same document DOES raise the prescription. So they are refused loudly today, just not through a door this instrument can watch.The shape chosen, and why
The strictness and both tables move to the shared schema;
ServerRateLimitConfigSchemakeeps only what is genuinely server-only — its two bounds checks — and is nowRateLimitConfigSchema.superRefine(…)rather than a secondstrictObjectover the same shape object.That leaves one declaration and one door for both defs, which is load-bearing in three ways:
matched.length !== 1never fires and proof 4 keeps working for both defs. Declaring a secondstrictObjectover the same shape object would have restored the ambiguity in the other direction, where the match resolves to neither and both defs silently read "no evidence";shared/ledger row's own rationale — strictness decided at the consuming schema — is false for this shape, exactly as it was false forshared/protection.zod.ts. Of the two mounts only one re-postured;api/endpoint.zod.tsmounts it bare onapis[].rateLimit, a registered metadata type authored throughdefineStack({ apis }), the Studio form andPUT /meta/api/:name, where nothing re-postures it. The row is annotated for what is now the fourth instance of a shape that ledger has recorded three times.Controls
LIT — behaviour flips. Same probe, run against this branch and against the two source files restored to the base commit (ablation with an
EXIT/INT/TERMrestore trap; both ablated blobs verified bygit hash-objectagainst the base blob hashes, and the restore verified by an emptygit diff HEAD):shared/RateLimitConfig{ …valid, keyBy: 'ip' }{enabled, windowMs, maxRequests}— key goneUnrecognized key(s) on this rate-limit budget …: keyBy.+ thekeyByprescription{ …valid, store: 'redis' }storeprescription{ enabled: true, windowSeconds: 60, maxRequests: 100 }windowMs: 60000Did you mean windowSeconds → windowMs?apiendpoint whoserateLimitcarrieskeyByCensus leg of the same flip: 770 → 772 delivered, 9 → 7 not delivered.
DARK — reads what it must.
system/ServerRateLimitConfig, before and after: a legitimate budget parses to the same document;{ enabled: true }still materialises the same defaults;max: 5is still renamed tomaxRequests;maxRequests: 0andwindowMs: 0are still refused onpath: ['maxRequests']/['windowMs']with their own messages;{ …valid, keyBy: 'ip' }is still refused carrying the prescription bullet. A legitimate endpoint document with a legitimaterateLimitstill parses, before and after. The census's 258 / 779 are unchanged, so the closed twin's declaration resolution did not move either.additionalProperties: false, and both still match one declaration by per-entry instance identity — neither of the two cheap instruments moved, and neither was used.this rate-limit budget (server.security.rateLimit, or an endpoint's rateLimit)rather thanserver.security.rateLimitalone, and thehistorysentence is the shared one. The verdict, the issue codes, the accept set and the prescription text are unchanged, and no test pinned the old prose. It is named here rather than left for a reviewer to find.The pin this rots, and where it went
scripts/build-schemas-check-mode.test.ts's#18301DOOR pin used this defect as a LIVE fixture, and its guard fired with its own prescription — "its door closed, so this fixture no longer models an open def sharing a closed declaration's shape; re-pick the pair". Re-picked:ui/ViewItem:confg, a def whose declaration NAMES the key and whose delivery the probe cannot watch. Proof 4 must refuse the deletion, in the words that say a declaration exists, and must not be waived by proof 2 either. Both halves of the fixture's own validity are guarded loudly: the bare document must carry nounrecognized_keysissue at all, and the whole document must raise the prescription.The census says there is no remaining def that ACCEPTS a promised key and drops it, so no live pair could model the original shape — which is the point of the card.
Verification
pnpm --filter @objectstack/spec build— greenpnpm --filter @objectstack/spec check:generated— 15 artefacts; onlystrictness-ledger.counts.mdwas stale (regenerated withgen:strictness-ledger,system/351 → 350). ⭐check:authorable-surface,check:api-surface,check:docs,check:declaration-mapandcheck:export-originsall pass with no regeneration: the published JSON Schema and the API surface are byte-unchanged, because inio: 'output'zod already emittedadditionalProperties: falsefor the stripping shape.pnpm --filter @objectstack/spec typecheck— greenpnpm --filter @objectstack/spec test— 486 files / 14053 tests pass, 1 skippedpnpm --filter @objectstack/spec test:repo— 31 files / 536 tests pass (the re-picked#18301pin is in here)check:strictness-ledger·check:yaml-examples·check:cross-package-test-inputs·check:test-source-alias·check:spec-parsed-alias·check:doc-authoring·check:spec-docblock-symbol-anchors·check:pm-widening-tells·check:nul-bytes— greencheck-adr-0087-registration --base origin/main— green; 1 declared-breaking changeset carryingnot-required (no-migration-prescription)check-changeset-no-major --base origin/main·check-empty-changeset --base origin/main— greencheck:skill-examples— NOT MEASURED: it refuses before judging any surface becausepackages/client-react/distholds no declarations in this worktree. A prerequisite, not a verdict.Blast radius, measured: every shipped
rateLimitblock writes only declared keys — three incontent/docs/, one inskills/objectstack-api, none at all inexamples/, theos inittemplates or thecreate-objectstackblank template.Acceptance notes
packages/spec/src/migrations/registry.ts(held by another seat). ⛔ That file is untouched, and the technical choice was not bent to avoid it: the disposition isnot-required (no-migration-prescription)on its own merits, and it is the same disposition, on the same stored metadata type, that the close ofApiEndpointSchemaitself took one level up — an undeclared key was never honoured, so nothing exists forobjectstack migrate metato rewrite, and the refused set is an open set of author typos rather than a renamed key.no (narrowing). The line is left exactly as dispatched, per the charter that the declaration is the seat's to align; the correction is named in the report rather than made here.strategy,burstCapacity,respectUpstreamLimits,rateLimitHeaders) is refused by the closed budget with no wrong-layer pointer — the rejection is correct and loud, and aguidanceentry naming where outbound throttling belongs would be an improvement rather than a defect repair. Carrier: whoever next touchesshared/http.zod.ts.keyByprescription points atserver.trustProxyfor how the caller IP is read, which is accurate on both mounts but is written in server language; it now reaches endpoint authors too. Carrier: none — no PR or person is queued on this file.packages/spec/src/system/http-server.zod.ts, andServerRateLimitConfigSchemaactually lives inpackages/spec/src/system/stack-server.zod.ts(http-server.zod.ts's shape was retired). The premise otherwise verified exactly.Generated by Claude Code