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
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