Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/rate-limit-budget-unknown-keys-refused.md
Original file line number Diff line number Diff line change
@@ -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-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) this change retires NO key. The budget vocabulary is byte-identical and only the unknown-key POSTURE moves, from strip to reject, on one of its two mounts. Nothing exists for `objectstack migrate meta` to rewrite, because an undeclared key was never honoured: it was dropped at parse, so neither the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`), nor the endpoint policy chain, nor the publish gate ever read one — measured with the gate's own instrument, which reports this def as accepting the key and returning a document without it. There is no single FROM/TO rule a ledger entry could state either, since what is now refused is an open set of author typos rather than a renamed key. The upgrade channel is the schema rejection itself, which is strictly more specific than any ledger line: it names the offending key at the author's own path and carries either the canonical spelling or the wrong-layer pointer. This is the same disposition, on the same stored metadata type, that #5384 took one level up when it closed `ApiEndpointSchema` itself; the `declarative-apis-endpoints-live` entry that governs this surface is already registered for protocol 17 and needs no change here. -->

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

Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,4 @@ directory rather than per file.
| `marketplace/` | 29 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 351 |
| `system/` | 350 |
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading