From f9e37a9af071705ece7abb55346dc93f78f1949b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 00:30:54 +0000 Subject: [PATCH 1/3] fix(spec): close the shared rate-limit budget so one declaration answers one door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- ...07-unknown-key-strictness-ledger.counts.md | 2 +- .../spec/src/integration/connector.test.ts | 33 ++++++- packages/spec/src/shared/http.zod.ts | 86 ++++++++++++++----- packages/spec/src/system/stack-server.zod.ts | 49 ++++------- 4 files changed, 113 insertions(+), 57 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index f81ac8b9742..ea97a5fc28c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,4 +264,4 @@ directory rather than per file. | `marketplace/` | 29 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 351 | +| `system/` | 350 | diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 38496daef8b..bf9d44f754a 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -747,10 +747,35 @@ describe('[#4911] `./integration` no longer publishes an outbound rate-limit sha windowMs: 60000, maxRequests: 100, }); - // It still strips outbound-shaped keys — pinned, not fixed: correct - // behaviour for a non-strict schema, and the reason it is NOT the - // replacement for the retired key. - expect(sharedEntry.RateLimitConfigSchema.parse({ windowSeconds: 60, strategy: 'token_bucket' })) + // It REFUSES outbound-shaped keys now — this assertion used to pin the + // opposite ("still strips them — pinned, not fixed: correct behaviour for a + // non-strict schema"), and the strip was the defect, not the posture. Both + // keys below are the retired outbound vocabulary, and each answers + // differently on purpose: + // + // - `windowSeconds` is an ALIAS the shared declaration curates, so the + // author is renamed onto `windowMs`. Under the old strip it parsed green + // and metered 60000 ms — a thousandfold miss on a key whose whole job is + // to bound spend, reported as success. + // - `strategy` has no inbound counterpart at all and no near-miss inside + // the budget, so it is refused with no rename — which is the right + // answer: the shared INBOUND budget is still NOT the replacement for the + // retired outbound key, and rewriting one into the other would throttle + // the wrong direction (the upgrade guide's own words). + const outbound = sharedEntry.RateLimitConfigSchema.safeParse({ + windowSeconds: 60, + strategy: 'token_bucket', + }); + expect(outbound.success).toBe(false); + const outboundMessage = outbound.success + ? '' + : outbound.error.issues.map((i) => i.message).join('\n'); + expect(outboundMessage).toMatch(/`windowSeconds` → `windowMs`/); + expect(outboundMessage).not.toMatch(/`strategy` →/); + // Dark leg for the two above: the declared keys still parse, so the refusal + // is attributable to the outbound spellings and not to a shape that has + // stopped accepting anything. + expect(sharedEntry.RateLimitConfigSchema.parse({ windowMs: 60_000, maxRequests: 100 })) .toEqual({ enabled: false, windowMs: 60000, maxRequests: 100 }); }); diff --git a/packages/spec/src/shared/http.zod.ts b/packages/spec/src/shared/http.zod.ts index fe3b000eddc..6b4ebf66267 100644 --- a/packages/spec/src/shared/http.zod.ts +++ b/packages/spec/src/shared/http.zod.ts @@ -34,6 +34,7 @@ import { z } from 'zod'; * this shape. */ import { lazySchema } from './lazy-schema'; +import { strictObject } from './strict-object'; export const HttpMethod = z.enum([ 'GET', 'POST', @@ -153,17 +154,36 @@ export type CorsConfigParsed = z.infer; // ========================================== /** - * Rate Limit Configuration Schema - * + * Rate Limit Configuration Schema — the one inbound budget shape, closed here. + * * Used by: - * - api/endpoint.zod.ts (ApiEndpointSchema) - * - system/stack-server.zod.ts (ServerRateLimitConfigSchema — this shape reused - * verbatim and closed against unknown keys; the LIVE inbound token bucket) + * - api/endpoint.zod.ts (ApiEndpointSchema — `apis[].rateLimit`, the per-endpoint + * budget the policy chain meters in its own namespace) + * - system/stack-server.zod.ts (ServerRateLimitConfigSchema — `server.security + * .rateLimit`, the LIVE inbound token bucket; it adds bounds checks and nothing + * else, so this declaration is the door for both) * * (`system/http-server.zod.ts` embedded this as `HttpServerConfig.security * .rateLimit` until #4938 retired that shape; the budget itself was not lost — * #5006 activated it on the narrow `server:` block.) * + * ## Why the door is HERE and not at the consumer + * + * The `shared/` ledger row's rationale is that strictness is decided at the + * consuming schema. It is false for this shape, the way it was false for + * `shared/protection.zod.ts`: of the two mounts only ONE re-postures + * (`ServerRateLimitConfigSchema`), and it re-postured by building `strictObject` + * **from this 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 the + * two `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 — a thousandfold miss, reported as success. + * + * Closing it here makes the two defs one door: the declaration below is matched + * by both, and now kept by both. + * * @example * { * "enabled": true, @@ -171,22 +191,46 @@ export type CorsConfigParsed = z.infer; * "maxRequests": 100 * } */ -export const RateLimitConfigSchema = lazySchema(() => z.object({ - /** - * Enable rate limiting - */ - enabled: z.boolean().default(false).describe('Enable rate limiting'), - - /** - * Time window in milliseconds - */ - windowMs: z.number().int().default(60000).describe('Time window in milliseconds'), - - /** - * Max requests per window - */ - maxRequests: z.number().int().default(100).describe('Max requests per window'), -})); +export const RateLimitConfigSchema = lazySchema(() => strictObject( + { + surface: "this rate-limit budget (`server.security.rateLimit`, or an endpoint's `rateLimit`)", + history: + 'Until this shape was closed, an unknown key here was accepted and dropped wherever the ' + + 'budget is mounted bare — an endpoint whose budget was misspelled metered at the defaults ' + + 'and said nothing. On `server.security.rateLimit` an unknown key was never accepted.', + aliases: { + window: 'windowMs', + windowSeconds: 'windowMs', + max: 'maxRequests', + maxRequest: 'maxRequests', + limit: 'maxRequests', + }, + guidance: { + keyBy: + 'The rate-limit key is not authorable. It is the resolved principal, falling back to the caller IP for ' + + 'anonymous traffic; whether the IP is read from forwarded headers is decided by `server.trustProxy`.', + store: + 'The counter store is not authorable. Counters live in the kernel `cache` service when one is registered ' + + '(ADR-0069 D2) and degrade to a per-process store otherwise, announced once at boot.', + }, + }, + { + /** + * Enable rate limiting + */ + enabled: z.boolean().default(false).describe('Enable rate limiting'), + + /** + * Time window in milliseconds + */ + windowMs: z.number().int().default(60000).describe('Time window in milliseconds'), + + /** + * Max requests per window + */ + maxRequests: z.number().int().default(100).describe('Max requests per window'), + }, +)); export type RateLimitConfig = z.input; /** Post-parse shape of {@link RateLimitConfig} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts index 807254ef44c..caba71a3865 100644 --- a/packages/spec/src/system/stack-server.zod.ts +++ b/packages/spec/src/system/stack-server.zod.ts @@ -68,39 +68,26 @@ import { strictObject } from '../shared/strict-object'; import { RateLimitConfigSchema } from '../shared/http.zod'; /** - * `server.security.rateLimit` — the shared {@link RateLimitConfigSchema} shape, - * closed against unknown keys for this authoring surface. + * `server.security.rateLimit` — the shared {@link RateLimitConfigSchema}, plus + * the two bounds this surface refuses. * - * The SHAPE is reused verbatim (`RateLimitConfigSchema.shape`) rather than - * retyped, so there is no fourth rate-limit shape in the repo and no drift to - * police — #4686 opened on there already being three. What is added is - * strictness: this key is new, so it joins the #4001 ratchet at birth instead of - * being tightened later, and a misspelled budget (`maxRequest`, `window`) is - * rejected at parse rather than silently defaulted to 100 req/min. + * The shape is reused verbatim rather than retyped, so there is no fourth + * rate-limit shape in the repo and no drift to police — #4686 opened on there + * already being three. + * + * ⚠️ **It reuses the shared schema, not the shared schema's `.shape`, and that + * is the whole of this declaration.** Building `strictObject(…, + * RateLimitConfigSchema.shape)` here is what put ONE declaration in front of TWO + * emitted defs — this closed one and `shared/RateLimitConfig`, which was a plain + * open `z.object` — so the `guidance` entries below were delivered on + * `server.security.rateLimit` and silently dropped on every bare mount of the + * same shape. The strictness and the tables moved to the shared schema + * (`shared/http.zod.ts`), where both defs inherit them; declaring a SECOND + * `strictObject` over the same shape object would restore the two-declaration + * ambiguity in the other direction, where the declaration match resolves to + * neither. What is left here is what is genuinely server-only: the bounds. */ -export const ServerRateLimitConfigSchema = lazySchema(() => strictObject( - { - surface: 'server.security.rateLimit', - history: - 'This key is new in v17 and strict from birth — an unknown key here was never accepted.', - aliases: { - window: 'windowMs', - windowSeconds: 'windowMs', - max: 'maxRequests', - maxRequest: 'maxRequests', - limit: 'maxRequests', - }, - guidance: { - keyBy: - 'The rate-limit key is not authorable. It is the resolved principal, falling back to the caller IP for ' - + 'anonymous traffic; whether the IP is read from forwarded headers is decided by `server.trustProxy`.', - store: - 'The counter store is not authorable. Counters live in the kernel `cache` service when one is registered ' - + '(ADR-0069 D2) and degrade to a per-process store otherwise, announced once at boot.', - }, - }, - RateLimitConfigSchema.shape, -).superRefine((value, ctx) => { +export const ServerRateLimitConfigSchema = lazySchema(() => RateLimitConfigSchema.superRefine((value, ctx) => { // The shared shape declares `.int()` but no lower bound, so `0` and negatives // parse. They are not budgets: `maxRequests: 0` rejects every request // including your own health checks, and either zero makes the derived refill From 5075b3b70100a3e17fda2b647c9b865c8075be7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 00:59:05 +0000 Subject: [PATCH 2/3] test(spec): re-pick the #18301 door fixture, and record the close in the strictness ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- .../rate-limit-budget-unknown-keys-refused.md | 50 +++++ .../2026-07-unknown-key-strictness-ledger.md | 2 +- .../scripts/build-schemas-check-mode.test.ts | 200 ++++++++++++------ 3 files changed, 184 insertions(+), 68 deletions(-) create mode 100644 .changeset/rate-limit-budget-unknown-keys-refused.md diff --git a/.changeset/rate-limit-budget-unknown-keys-refused.md b/.changeset/rate-limit-budget-unknown-keys-refused.md new file mode 100644 index 00000000000..54521eab0e9 --- /dev/null +++ b/.changeset/rate-limit-budget-unknown-keys-refused.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse unknown keys inside a rate-limit budget — `RateLimitConfigSchema` goes strict, so one declaration stops answering for two doors + +**BREAKING** accept-set narrowing on a published spec schema, landing after the +v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). + +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: `system/ServerRateLimitConfig` refused +an undeclared `keyBy` and handed back the prescription, while +`shared/RateLimitConfig` — the same shape, mounted bare on `apis[].rateLimit` — +accepted the key and dropped it in silence. 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, a +thousandfold miss on the one key whose job is to bound spend, reported as +success. + +**What is refused:** any key the budget does not declare, wherever it is mounted, +with a message naming the surface and the offending key. A near miss carries the +declared spelling (`window` / `windowSeconds` are answered with `windowMs`; +`max` / `maxRequest` / `limit` with `maxRequests`). `keyBy` and `store` keep +their wrong-layer prescriptions — the limiter's key is the resolved principal +falling back to the caller IP, and its counters live in the kernel `cache` +service (ADR-0069 D2) — and those two now reach the author on both mounts +instead of one. + +**What stays accepted:** every declared key, byte-identically, with the same +defaults. `server.security.rateLimit` keeps its two bounds checks +(`maxRequests > 0`, `windowMs > 0`) and answers exactly as before. The published +JSON Schema, the authorable surface and the API surface are all unchanged — +`check:authorable-surface`, `check:api-surface` and `check:docs` pass with no +regeneration, because in `io: 'output'` zod already emitted +`additionalProperties: false` for the stripping shape too. + +**Breaking for metadata that was already silently broken.** An `apis[].rateLimit` +carrying an undeclared key now fails `objectstack validate`, `objectstack build` +and the metadata write path instead of publishing with the key discarded. +Measured blast radius before landing: every shipped `rateLimit` block writes +only declared keys — three in `content/docs/`, one in `skills/objectstack-api`, +and none at all in `examples/`, the `os init` templates or the +`create-objectstack` blank template, which declare no budget. + diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 2f05f4687a7..15b8bb124bb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -1374,7 +1374,7 @@ rest at #5107. | `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | | `integration/` | wire | connector payloads — upstream adds fields freely | | `identity/` | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified | -| `shared/` | **mixed · `protection.zod.ts` authorable, the rest as written** | ⚠️ **Annotated at #16845 — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** ⛔ **This directory is still UNTRIAGED and this is not a per-file row** — `shared/` has never had per-file rows, so `shared/protection.zod.ts` was **never enumerated** by this campaign; it was not deliberately accepted, because no verdict was ever taken on it. Two measurements say why the silence carried no information. **①** This row's own rationale — *strictness decided at the consuming schema* — is false for this file: all **16** `protection: ProtectionSchema` mounts across **14** files are the bare `ProtectionSchema.optional()`, not one `.extend()` / `.merge()` / `.omit()` / re-posture among them, so both the strictness and the MESSAGE are decided here in `shared/` and inherited verbatim by objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools and skills — very nearly every authorable metadata type in the platform. A row is read as licence, which is what this ledger's own gate exists to prevent. **②** Even a per-file row would not have surfaced the defect, and this is the part worth keeping: `ProtectionSchema` has been `.strict()` since it was introduced, so it counts in the `strict` column and could never carry a remaining-strip row. **This ratchet's axis is CLOSURE; the defect was MESSAGE QUALITY** — a closed shape with no error map, refusing `lockk` with zod's bare `Unrecognized key: "lockk"`: no surface, no declared-key list, no rename, on every one of those mounts. `strictObject` at #16845 (the `PluginPermissionsSchema` conversion at #16328 one directory over is the precedent). ⛔ The population of *closed shapes still carrying zod's bare message* is **NOT MEASURED** here and is not this row's claim — #14722's rule stands that a sweep needs its own card with its own measured count. The rest of `shared/` is unchanged: utilities and building blocks whose strictness really is decided at the consuming schema | +| `shared/` | **mixed · `protection.zod.ts` and `http.zod.ts`'s rate-limit budget authorable, the rest as written** | ⚠️ **Annotated again at #18578 — a FOURTH instance of the same shape, and this one was invisible to a sweep that went looking for it.** `shared/http.zod.ts`'s `RateLimitConfigSchema` is a building block with two mounts, and this row's rationale — *strictness decided at the consuming schema* — held for exactly one of them: `system/stack-server.zod.ts` re-postured it closed, and it did so by building `strictObject(…, RateLimitConfigSchema.shape)` **from this shape object**, so ONE declaration answered for TWO emitted defs with OPPOSITE doors. `api/endpoint.zod.ts` mounts the same schema BARE on `apis[].rateLimit` — a registered metadata type since #5312, authored through `defineStack({ apis })`, the Studio form and `PUT /meta/api/:name` — where nothing re-postures it, so the two `guidance` entries (`keyBy`, `store`) prescribed to nobody and a misspelled budget was worse: `windowSeconds: 60` parsed green and metered the 60000 ms default, a thousandfold miss reported as success. ⚠️ **The sweep that should have caught it could not.** A `CONTRACT_REVIEW_TIER` review swept this exact class with live controls (5 `.strip()` sites, 0 `z.object(XSchema.shape…)`, 0 `z.object(bareIdentifier)`) and found nothing, because the sharing runs the other way round here: it hunted an OPEN clone built from a STRICT schema's shape, and this is the STRICT one built from the OPEN one's shape — every grep shape in that sweep is blind to that direction by construction. It was found instead by driving the gate's own instrument (`computeGuidanceRoutes()` in `scripts/build-schemas.ts`) over every emitted def: match each def to its declaration, then ask the def what it actually does with the key. Census on `42f8df1723`: 1527 emitted defs, 258 resolving to exactly one declaration, 779 keys promised, **770 delivered, 9 not** — 2 of them this live silent strip, the other 7 union defs whose discriminator the probe's one-key document cannot supply (they DO deliver to an author who writes a whole document). The close moves the strictness and both tables onto the shared schema, leaving ONE declaration and ONE door for both defs: 772 delivered, 7 not. ⚠️ The residual 7 are a PROBE boundary, ⛔ not a clean zero and ⛔ not a finding. ⚠️ **Annotated at #16845 — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** ⛔ **This directory is still UNTRIAGED and this is not a per-file row** — `shared/` has never had per-file rows, so `shared/protection.zod.ts` was **never enumerated** by this campaign; it was not deliberately accepted, because no verdict was ever taken on it. Two measurements say why the silence carried no information. **①** This row's own rationale — *strictness decided at the consuming schema* — is false for this file: all **16** `protection: ProtectionSchema` mounts across **14** files are the bare `ProtectionSchema.optional()`, not one `.extend()` / `.merge()` / `.omit()` / re-posture among them, so both the strictness and the MESSAGE are decided here in `shared/` and inherited verbatim by objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools and skills — very nearly every authorable metadata type in the platform. A row is read as licence, which is what this ledger's own gate exists to prevent. **②** Even a per-file row would not have surfaced the defect, and this is the part worth keeping: `ProtectionSchema` has been `.strict()` since it was introduced, so it counts in the `strict` column and could never carry a remaining-strip row. **This ratchet's axis is CLOSURE; the defect was MESSAGE QUALITY** — a closed shape with no error map, refusing `lockk` with zod's bare `Unrecognized key: "lockk"`: no surface, no declared-key list, no rename, on every one of those mounts. `strictObject` at #16845 (the `PluginPermissionsSchema` conversion at #16328 one directory over is the precedent). ⛔ The population of *closed shapes still carrying zod's bare message* is **NOT MEASURED** here and is not this row's claim — #14722's rule stands that a sweep needs its own card with its own measured count. The rest of `shared/` is unchanged: utilities and building blocks whose strictness really is decided at the consuming schema | | `qa/` | n/a | test fixtures | ## Next steps (verify-then-enforce, one shape at a time) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index d6e149d3764..69c3f5e3699 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -81,6 +81,7 @@ import { MetricSchema } from '../src/data/analytics.zod'; import { SchemaLevelIsolationStrategySchema } from '../src/system/tenant.zod'; import { RateLimitConfigSchema } from '../src/shared/http.zod'; import { ServerRateLimitConfigSchema } from '../src/system/stack-server.zod'; +import { ViewItemSchema } from '../src/ui/view.zod'; import { AUTHORABLE_SURFACE_DIR_NAME, SCHEMA_MANIFEST_DIR_NAME, @@ -1088,28 +1089,54 @@ const WITHHELD_TOMBSTONE = 'integration/DataSyncConfig:schedule'; const PRESCRIPTION_BULLET = '\n • '; /** #18301's DOOR pin — and the reason the first cut of proof 4 was wrong. * - * `ServerRateLimitConfigSchema` is declared `strictObject({… guidance: { keyBy, - * store } }, RateLimitConfigSchema.shape)` — it is built FROM the open schema's - * own shape object. So ONE declaration is matched, by shape identity, by TWO - * emitted defs: the closed one it built, and `shared/RateLimitConfig`, a plain - * `z.object` that drops an unknown key in silence. Both emit + * `ServerRateLimitConfigSchema` USED to be declared `strictObject({… guidance: + * { keyBy, store } }, RateLimitConfigSchema.shape)` — built FROM the open + * schema's own shape object. So ONE declaration was matched, by shape identity, + * by TWO emitted defs: the closed one it built, and `shared/RateLimitConfig`, a + * plain `z.object` that dropped an unknown key in silence. Both emitted * `additionalProperties: false` (in `io: 'output'` zod says `false` for a - * non-closing shape too), and both satisfy the declaration match — so NEITHER of - * the two facts the first cut read can tell them apart, and it waived the open - * one. Measured on the head this fixture landed against: 2 such keys, on this - * def, reachable from the roots. + * non-closing shape too), and both satisfied the declaration match — so NEITHER + * of the two facts the first cut read could tell them apart, and it waived the + * open one. * - * This is the review's "strip-mode clone shares a strict shape" case in the - * spelling the tree actually holds — sharing in the other direction, which is - * why a sweep for `.strip()` and `z.object(X.shape)` found nothing. */ -const OPEN_TWIN_DEF = 'shared/RateLimitConfig'; -const CLOSED_TWIN_DEF = 'system/ServerRateLimitConfig'; -/** A key BOTH twins' one declaration prescribes for, and only one of them delivers. */ + * ⚠️ **#18578 closed that open twin, so this pair no longer models opposite + * doors — and re-picking it was the fixture's own instruction.** The strictness + * and the tables moved onto the shared schema, which is where both defs inherit + * them; the pair still shares ONE declaration and now keeps its promise on BOTH + * sides. That is what makes it the right DARK leg here: if anyone re-opens the + * shared shape, the two rows below stop being admitted and this test says so. + * + * The census that found the original case (the gate's own instrument, driven + * over every emitted def) reports no remaining def that ACCEPTS a promised key + * and drops it. What it does report is the other way proof 4's second half can + * come up empty, which is the LIT leg below. */ +const SHARED_TWIN_DEF = 'shared/RateLimitConfig'; +const SERVER_TWIN_DEF = 'system/ServerRateLimitConfig'; +/** A key the twins' one declaration prescribes for, and both now deliver. */ const TWIN_LEAF = 'keyBy'; -const DELETED_OPEN_TWIN = `${OPEN_TWIN_DEF}:${TWIN_LEAF}`; -const DELETED_CLOSED_TWIN = `${CLOSED_TWIN_DEF}:${TWIN_LEAF}`; +const DELETED_SHARED_TWIN = `${SHARED_TWIN_DEF}:${TWIN_LEAF}`; +const DELETED_SERVER_TWIN = `${SERVER_TWIN_DEF}:${TWIN_LEAF}`; /** A budget every twin accepts, so the door is the only thing the probe below reads. */ const TWIN_VALID = { enabled: true, windowMs: 60_000, maxRequests: 100 }; +/** #18578's LIT leg: a def whose declaration NAMES the key and which this gate + * cannot watch deliver it. + * + * `ui/ViewItem` is a discriminated union of two `strictObject` arms that share + * one `VIEW_ITEM_SURFACE` table, and `confg` is the one-letter typo that table + * exists for. 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 — measured, not assumed, and the same + * document written whole DOES raise the prescription + * (`ui/view-authoring-wire-split.test.ts`). So the key is promised, the author + * really is answered, and this gate has still watched no delivery. + * + * ⛔ That is exactly the state proof 4 must read as NO EVIDENCE rather than as + * proof: "the door is open" would be a guess here, and a wrong guess waives a + * deletion in the one direction this gate must not err in. Its verdict says only + * THAT the prescription did not arrive, never why. */ +const UNREACHED_DOOR_DEF = 'ui/ViewItem'; +const UNREACHED_DOOR_LEAF = 'confg'; +const DELETED_UNREACHED_DOOR = `${UNREACHED_DOOR_DEF}:${UNREACHED_DOOR_LEAF}`; describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => { beforeAll(() => { @@ -1126,8 +1153,9 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 DELETED_BY_RENAME, DELETED_GUIDANCE_ROUTE, DELETED_GUIDANCE_UNNAMED, - DELETED_OPEN_TWIN, - DELETED_CLOSED_TWIN, + DELETED_SHARED_TWIN, + DELETED_SERVER_TWIN, + DELETED_UNREACHED_DOOR, WITHHELD_TOMBSTONE, ]) { expect( @@ -1210,46 +1238,65 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 'stopped discriminating, so it no longer reads the guidance table', ).not.toContain(PRESCRIPTION_BULLET); // #18301's DOOR fixture is only a pin while the tree still holds ONE - // declaration answering for TWO defs with OPPOSITE doors. Each half rots on - // its own, and each rots the pin into a green that asserts nothing about the - // one direction this gate must not err in. + // declaration answering for TWO defs. Each half rots on its own, and each + // rots the pin into a green that asserts nothing about the one direction + // this gate must not err in. expect( Object.keys(RateLimitConfigSchema.shape), - `${OPEN_TWIN_DEF} and ${CLOSED_TWIN_DEF} no longer declare the same key SET — the ` + + `${SHARED_TWIN_DEF} and ${SERVER_TWIN_DEF} no longer declare the same key SET — the ` + `registry match is keyed off the sorted key set, so this fixture no longer reaches it`, ).toEqual(Object.keys(ServerRateLimitConfigSchema.shape)); expect( Object.entries(RateLimitConfigSchema.shape).every( ([name, prop]) => (ServerRateLimitConfigSchema.shape as Record)[name] === prop, ), - `${CLOSED_TWIN_DEF} no longer shares ${OPEN_TWIN_DEF}'s shape ENTRIES — the declaration ` + - `match is by instance identity, so the open twin would stop matching and this fixture ` + - `would pass while modelling nothing`, - ).toBe(true); - // The two doors, read as a lit/dark pair. The open one is the whole point: - // it ACCEPTS the key and drops it, which is the silent strip proof 4 must - // never waive a deletion on. - const openTwin = RateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); - const closedTwin = ServerRateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); - expect( - openTwin.success, - `${OPEN_TWIN_DEF} now REFUSES '${TWIN_LEAF}' — its door closed, so this fixture no longer ` + - `models an open def sharing a closed declaration's shape; re-pick the pair`, + `${SERVER_TWIN_DEF} no longer shares ${SHARED_TWIN_DEF}'s shape ENTRIES — the declaration ` + + `match is by instance identity, so the two defs would stop answering to one declaration ` + + `and this fixture would pass while modelling nothing`, ).toBe(true); + // Both doors, read as a pair. #18578 is the reason they agree: the shared + // schema carries the strictness and the tables, so the def mounted bare on + // `apis[].rateLimit` refuses the key with the same prescription the server + // key always got. Before that it ACCEPTED the key and dropped it, and proof 4 + // waiving THAT is what this whole block exists to prevent. + const sharedTwin = RateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); + const serverTwin = ServerRateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); + for (const [def, result] of [[SHARED_TWIN_DEF, sharedTwin], [SERVER_TWIN_DEF, serverTwin]] as const) { + expect( + result.success, + `${def} now ACCEPTS '${TWIN_LEAF}' — its door re-opened, so the DARK leg below would be ` + + `asserting that proof 4 admits a def which drops an authored key in silence`, + ).toBe(false); + expect( + result.success ? '' : result.error.issues.map((i) => i.message).join('\n'), + `${def} rejects '${TWIN_LEAF}' with no prescription — the \`guidance\` entry that is this ` + + `pair's evidence has gone`, + ).toContain(PRESCRIPTION_BULLET); + } + // #18578's LIT fixture is only a pin while the probe still cannot watch + // `ui/ViewItem` deliver. Both halves are loud: the bare document must fail + // BEFORE any arm's door (no `unrecognized_keys` at all), and the whole + // document must succeed in raising the prescription — otherwise the key is + // either delivered (and the fixture models nothing) or not prescribed for + // (and it models the wrong verdict). + const bareDoor = ViewItemSchema.safeParse({ [UNREACHED_DOOR_LEAF]: null }); expect( - openTwin.success && TWIN_LEAF in (openTwin.data as Record), - `${OPEN_TWIN_DEF} now CARRIES '${TWIN_LEAF}' through the parse — it is neither refusing ` + - `nor stripping, so the fixture no longer models a silent strip`, + bareDoor.success, + `${UNREACHED_DOOR_DEF} now ACCEPTS a bare '${UNREACHED_DOOR_LEAF}' — re-pick the fixture`, ).toBe(false); expect( - closedTwin.success, - `${CLOSED_TWIN_DEF} now ACCEPTS '${TWIN_LEAF}' — the closed twin opened, so the pair no ` + - `longer discriminates`, - ).toBe(false); + bareDoor.success ? [] : bareDoor.error.issues.map((i) => i.code), + `${UNREACHED_DOOR_DEF} now answers the PROBE's own document with an unrecognized-key ` + + `issue — the probe reaches a door after all, so this def no longer models the boundary`, + ).not.toContain('unrecognized_keys'); + const wholeDoor = ViewItemSchema.safeParse({ + name: 'a.b', object: 'a', viewKind: 'list', [UNREACHED_DOOR_LEAF]: { columns: [] }, + }); expect( - closedTwin.success ? '' : closedTwin.error.issues.map((i) => i.message).join('\n'), - `${CLOSED_TWIN_DEF} rejects '${TWIN_LEAF}' with no prescription — the \`guidance\` entry ` + - `that is the lit half of this pair has gone`, + wholeDoor.success ? '' : wholeDoor.error.issues.map((i) => i.message).join('\n'), + `'${UNREACHED_DOOR_LEAF}' no longer raises its prescription on a WHOLE ${UNREACHED_DOOR_DEF} ` + + `document — the declaration this fixture is about has gone, so the gate's verdict would ` + + `be 'nothing prescribes' rather than 'prescribed and not delivered'`, ).toContain(PRESCRIPTION_BULLET); // The manifest ratchet runs first; keep it current so every run reaches (c). seedManifest((s) => s); @@ -1543,16 +1590,19 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 ); it( - '#18301 — proof 4 reads the DOOR, not the registry: one declaration, two defs, opposite verdicts', + '#18301 — proof 4 reads the DOOR, not the registry: a promise the gate cannot watch kept is no proof', { timeout: SPAWN_TIMEOUT_MS }, () => { // The case the contract review found, pinned in the spelling the tree - // really holds. `ServerRateLimitConfigSchema` is - // `strictObject({… guidance: { keyBy, store } }, RateLimitConfigSchema.shape)`, - // so ONE declaration answers for TWO emitted defs by shape identity — and - // only one of them ever closed its door. + // really holds — and #18578 moved that spelling, so read both halves. // - // Everything the first cut of proof 4 read says the two are the same: + // ## What this used to pin, and why it could not stay + // + // `ServerRateLimitConfigSchema` was + // `strictObject({… guidance: { keyBy, store } }, RateLimitConfigSchema.shape)`, + // so ONE declaration answered for TWO emitted defs by shape identity — and + // only one of them had ever closed its door. Everything the first cut of + // proof 4 read said the two were the same: // // - both emit `additionalProperties: false` (measured: in `io: 'output'` // zod says `false` for a non-closing shape too — the ledger reading at @@ -1562,12 +1612,21 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // one's shape object; // - both are reachable, so proof 2 answers for neither. // - // So a proof 4 that reads either of those facts waives the OPEN twin's - // deletion while an author who keeps writing `keyBy` has it dropped in - // silence — the one direction this gate must not err in, and the direction - // the whole #4001 campaign exists to kill. Only writing the key at the def - // and reading the answer separates them. - seedBase((s) => [...s, DELETED_OPEN_TWIN, DELETED_CLOSED_TWIN].sort()); + // #18578 closed the open twin rather than leaving a live silent strip in + // the tree for this test to point at, which is what the fixture guard's own + // "re-pick the pair" instruction prescribes. The pair is now the DARK leg. + // + // ## What it pins now + // + // The invariant is unchanged and is the only one that matters here: proof 4 + // admits a deletion ONLY where it has watched the def answer, and reads + // every other state as no evidence. So the LIT leg is a def whose + // declaration NAMES the key and whose delivery this gate cannot observe — + // `ui/ViewItem`, a discriminated union the probe's one-key document cannot + // drive past `viewKind` to any arm's door. Its author IS answered; this + // gate has still seen nothing, and guessing "the door is open" or "the door + // is closed" are both wrong here. It refuses. + seedBase((s) => [...s, DELETED_SHARED_TWIN, DELETED_SERVER_TWIN, DELETED_UNREACHED_DOOR].sort()); const canonical = seedSurface((s) => s); const rx = (key: string, tail: string): RegExp => @@ -1575,26 +1634,33 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 const eager = run(['--check'], EAGER_SCHEMAS_ENV); - // The CLOSED twin: admitted by proof 4, on the door's own evidence. + // DARK — BOTH twins are admitted by proof 4, on the door's own evidence. + // One declaration, two defs, and since #18578 one door: the def mounted + // bare on `apis[].rateLimit` answers exactly as the server key does. The + // `shared/` row is the regression guard for that card — re-open the shared + // shape and it stops being admitted here. expect(eager.output).toContain('carry their own proof (#4650)'); expect(eager.output).toMatch( - rx(DELETED_CLOSED_TWIN, `def .*; writing '${TWIN_LEAF}' on it is REFUSED as an unrecognized key`), + rx(DELETED_SERVER_TWIN, `def .*; writing '${TWIN_LEAF}' on it is REFUSED as an unrecognized key`), + ); + expect(eager.output).toMatch( + rx(DELETED_SHARED_TWIN, `def .*; writing '${TWIN_LEAF}' on it is REFUSED as an unrecognized key`), ); - // The OPEN twin: refused — and refused in words that name what is actually - // missing. Its `guidance` entry exists; what does not exist is a door for - // it to be delivered through, so the plain "was LIVE (never tombstoned)" - // verdict would send its reader to write an entry that is already there. + // LIT — refused, and refused in words that name what is actually missing. + // The `guidance` entry exists; what this gate could not obtain is a reading + // of it being delivered, so the plain "was LIVE (never tombstoned)" verdict + // would send its reader to write an entry that is already there. expect(eager.status).toBe(1); expect(eager.output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); expect(eager.output).toMatch( - rx(DELETED_OPEN_TWIN, `def .*; a \`strictObject\` declaration NAMES '${TWIN_LEAF}', but writing it`), + rx(DELETED_UNREACHED_DOOR, `def .*; a \`strictObject\` declaration NAMES '${UNREACHED_DOOR_LEAF}', but writing it`), ); - expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'def .*is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(DELETED_UNREACHED_DOOR, 'def .*is REFUSED as an unrecognized key')); // …and it is not being waived by some OTHER proof either. The def is // root-reachable, so proof 2 must not answer for it — without this leg the // case would pass on a gate that had simply stopped emitting proof 4 at all. - expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'def not reachable from the')); + expect(eager.output).not.toMatch(rx(DELETED_UNREACHED_DOOR, 'def not reachable from the')); expect(readSurface()).toBe(canonical); }, From 35b84505d2ac16ee31a2b841119088d186b0b959 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 01:32:27 +0000 Subject: [PATCH 3/3] chore(changeset): the rate-limit budget arm reads narrowing, which is the direction it moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- .changeset/rate-limit-budget-unknown-keys-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rate-limit-budget-unknown-keys-refused.md b/.changeset/rate-limit-budget-unknown-keys-refused.md index 54521eab0e9..14f57b43c85 100644 --- a/.changeset/rate-limit-budget-unknown-keys-refused.md +++ b/.changeset/rate-limit-budget-unknown-keys-refused.md @@ -7,7 +7,7 @@ feat(spec): refuse unknown keys inside a rate-limit budget — `RateLimitConfigS **BREAKING** accept-set narrowing on a published spec schema, landing after the v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). -Clause-②: yes (widening) +Clause-②: no (narrowing)