Skip to content

fix(spec): close the shared rate-limit budget — one declaration was answering two doors, and one of them dropped the key in silence - #18861

Merged
os-bill merged 4 commits into
mainfrom
claude/issue-18578-ratelimit-open-twin-guidance
Sep 18, 2026
Merged

os-bill merged 4 commits into
mainfrom
claude/issue-18578-ratelimit-open-twin-guidance

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18578

Clause-②: yes (widening)

ServerRateLimitConfigSchema was declared strictObject({ … 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:

def door before an authored keyBy
system/ServerRateLimitConfig closed refused, loudly, with the prescription
shared/RateLimitConfig plain open z.object accepted, then dropped in silence

The two guidance entries prescribed to nobody on the open twin. And a misspelled budget was the same story one key over: on the bare mount windowSeconds: 60 parsed 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_TIER sweep 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() in packages/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 (current main when this branch was cut), and again on this branch:

reading before after
emitted defs 1527 1527
strictObject declarations registered 551 551
defs resolving to exactly one declaration 258 258
… of those, naming at least one undeclared key 147 147
keys promised 779 779
keys delivered 770 772
keys not delivered 9 7

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

  • 2shared/RateLimitConfig:keyBy and :store, the live members this card is about. Probe verdict accepted-and-stripped.
  • 7ui/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 answers invalid_union on viewKind before 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; ServerRateLimitConfigSchema keeps only what is genuinely server-only — its two bounds checks — and is now RateLimitConfigSchema.superRefine(…) rather than a second strictObject over the same shape object.

That leaves one declaration and one door for both defs, which is load-bearing in three ways:

  1. the declaration match still resolves to exactly one declaration, so matched.length !== 1 never fires and proof 4 keeps working for both defs. Declaring a second strictObject over 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";
  2. the closed twin's verdict is untouched — same acceptances, same refusals, same prescription bullet, same bounds;
  3. the shared/ ledger row's own rationale — strictness decided at the consuming schema — is false for this shape, exactly as it was false for shared/protection.zod.ts. Of the two mounts only one re-postured; api/endpoint.zod.ts mounts it bare on apis[].rateLimit, a registered metadata type authored through defineStack({ apis }), the Studio form and PUT /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/TERM restore trap; both ablated blobs verified by git hash-object against the base blob hashes, and the restore verified by an empty git diff HEAD):

written on shared/RateLimitConfig before after
{ …valid, keyBy: 'ip' } PARSED OK → {enabled, windowMs, maxRequests} — key gone REFUSED, Unrecognized key(s) on this rate-limit budget …: keyBy. + the keyBy prescription
{ …valid, store: 'redis' } PARSED OK, key gone REFUSED + the store prescription
{ enabled: true, windowSeconds: 60, maxRequests: 100 } PARSED OK → windowMs: 60000 REFUSED, Did you mean windowSeconds → windowMs?
an api endpoint whose rateLimit carries keyBy PARSED OK, key gone REFUSED with the same prescription, at the author's own path

Census 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: 5 is still renamed to maxRequests; maxRequests: 0 and windowMs: 0 are still refused on path: ['maxRequests'] / ['windowMs'] with their own messages; { …valid, keyBy: 'ip' } is still refused carrying the prescription bullet. A legitimate endpoint document with a legitimate rateLimit still parses, before and after. The census's 258 / 779 are unchanged, so the closed twin's declaration resolution did not move either.

⚠️ The two defs are told apart, as the card requires. The probe reads them by def key through separate schema instances, and the discriminating reading is the PARSE ANSWER, not the emitted artefact: both still emit additionalProperties: false, and both still match one declaration by per-entry instance identity — neither of the two cheap instruments moved, and neither was used.

⚠️ One disclosure inside DARK. The server key's refusal MESSAGE changes, because the two surfaces now share one declaration: the surface prose reads this rate-limit budget (server.security.rateLimit, or an endpoint's rateLimit) rather than server.security.rateLimit alone, and the history sentence 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 #18301 DOOR 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:

  • DARK leg — the rate-limit twins, now one declaration, two defs and one door. Both rows must be ADMITTED by proof 4, so re-opening the shared shape turns this test red. That is this card's regression guard.
  • LIT legui/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 no unrecognized_keys issue 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 — green
  • pnpm --filter @objectstack/spec check:generated — 15 artefacts; only strictness-ledger.counts.md was stale (regenerated with gen:strictness-ledger, system/ 351 → 350). ⭐ check:authorable-surface, check:api-surface, check:docs, check:declaration-map and check:export-origins all pass with no regeneration: the published JSON Schema and the API surface are byte-unchanged, because in io: 'output' zod already emitted additionalProperties: false for the stripping shape.
  • pnpm --filter @objectstack/spec typecheck — green
  • pnpm --filter @objectstack/spec test — 486 files / 14053 tests pass, 1 skipped
  • pnpm --filter @objectstack/spec test:repo — 31 files / 536 tests pass (the re-picked #18301 pin 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 — green
  • check-adr-0087-registration --base origin/main — green; 1 declared-breaking changeset carrying not-required (no-migration-prescription)
  • check-changeset-no-major --base origin/main · check-empty-changeset --base origin/main — green
  • check:skill-examples — NOT MEASURED: it refuses before judging any surface because packages/client-react/dist holds no declarations in this worktree. A prerequisite, not a verdict.

Blast radius, measured: every shipped rateLimit block writes only declared keys — three in content/docs/, one in skills/objectstack-api, none at all in examples/, the os init templates or the create-objectstack blank template.

Acceptance notes

  • The ADR-0087 boundary was measured, and it does not fire. The dispatch fenced 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 is not-required (no-migration-prescription) on its own merits, and it is the same disposition, on the same stored metadata type, that the close of ApiEndpointSchema itself took one level up — an undeclared key was never honoured, so nothing exists for objectstack migrate meta to rewrite, and the refused set is an open set of author typos rather than a renamed key.
  • The declaration line above is copied verbatim from the dispatch, and the measurement disagrees with it. No key is added to a published payload here; what moves is the accept set, and it moves DOWN. On the arms this repo uses, the measured reading is 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.
  • Noted, not filed: the retired outbound connector vocabulary (strategy, burstCapacity, respectUpstreamLimits, rateLimitHeaders) is refused by the closed budget with no wrong-layer pointer — the rejection is correct and loud, and a guidance entry naming where outbound throttling belongs would be an improvement rather than a defect repair. Carrier: whoever next touches shared/http.zod.ts.
  • Noted, not filed: the keyBy prescription points at server.trustProxy for 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.
  • Noted, not filed: the dispatch's file surface named packages/spec/src/system/http-server.zod.ts, and ServerRateLimitConfigSchema actually lives in packages/spec/src/system/stack-server.zod.ts (http-server.zod.ts's shape was retired). The premise otherwise verified exactly.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 18, 2026
@os-bill os-bill added domain:spec priority:p2 Medium: important, M3 labels Sep 18, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via maxRequests (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema), windowMs (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema))
  • content/docs/getting-started/quick-reference.mdx (via maxRequests (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema), windowMs (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema))
  • content/docs/protocol/kernel/http-protocol.mdx (via maxRequests (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema), server.security.rateLimit (literal, a string literal in ServerRateLimitConfigSchema), windowMs (literal, a string literal in RateLimitConfigSchema; a string literal in ServerRateLimitConfigSchema))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json be7aeb82756d054af451c7b248686d878b311210packageMentionDocs.

Which tree this was computed on

This run read content/docs from a0bafb0382fadbdbffb2e35e0fbc028b6c6dc83f — the merge of head 35b84505d2ac16ee31a2b841119088d186b0b959 into base be7aeb82756d054af451c7b248686d878b311210, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs be7aeb82756d054af451c7b248686d878b311210 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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

os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

条款② 收口:申报已翻成 no (narrowing),载体整对摘除

⏱️ 2026-09-18T01:34Z。head = 35b84505d2

三处申报的现状,以及本席为什么只改了两处

载体 现值 谁读它
#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」。⚠️ 加上本班实测过的代价:裸 REST 改正文会把署名页脚换成不带 session id 的裸形(#18840 已中过一次)。⇒ 为一行无人读的字去换掉一条归属记录,是拿真损失换假整洁。 本条评论即那一行的更正载体。

载体是「从未适用」,⛔ 不是「已清」

needs:contract-review 原挂在卡 #18578 上(PR 上未挂)。申报落到 no 之后,该闸对本卡从一开始就不适用 ⇒ 本席整对摘除

⚠️ ⛔ 不得把这次摘除读作「有过一次 PASS」 —— 本卡没有、也不需要契约复核记录。这与 #18851 那次形成对照:那张是 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation domain:spec priority:p2 Medium: important, M3 protocol:system size/m tests tooling

Projects

None yet

2 participants