Skip to content

Commit eaed61f

Browse files
os-zhuangclaude
andauthored
feat(spec)!: close ApiEndpointSchema against undeclared keys, and name the author state on the upgrade path (#5384, #5227) (#6840)
* feat(spec)!: close ApiEndpointSchema against undeclared keys, and name the author state on the upgrade path (#5384, #5227) `api` became a registered metadata type at #5312, making `endpoint.zod.ts` an authoring surface (`defineStack({ apis })`, the Studio form, `PUT /meta/api/:name`) while it was still an open `z.object`. A `cacheTTL` / `objectParam` / `outputMappings` typo parsed green, published green, and served without the policy or projection its author wrote. The shape is `strictObject` now, with curated wrong-layer prescriptions for `namespace` (ADR-0121 D2 — derived from `manifest.namespace`, never per-endpoint) and the six stored-envelope bookkeeping keys. The ORDER is the record: closing this was measured and refused first, because the same schema parsed STORED rows carrying `packageId` / `state`. #5309 (PR #6576) paid that debt at the layer that owned it (`peelStoredEnvelope`), after which the conversion was an ordinary #4001 one. `ApiEndpointSchema` never learned a bookkeeping key. Corpus measured before landing: examples/* = 2 endpoints, cloud = 0 endpoints, zero undeclared keys either side, so direction A carried and no key had to stay. `api` leaves STILL_STRIP (closed 24 -> 25 of 26; `view` alone remains) and the CLI gate row moves NOT_YET_CLOSED -> GATED_AT. #5227 needed no schema change: ADR-0122 phase 2 (PR #6279) already made `ApiEndpoint` the author state, so omitting `authRequired` compiles. The `declarative-apis-endpoints-live` upgrade-guide entry now says so with a usable annotation. An `ApiEndpointInput` alias is deliberately NOT added — after the flip it would be a synonym, which ADR-0122 D3 forbids and `check:spec-parsed-alias` rule 4 rejects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M * fix(spec): `package` was missing from the endpoint stored-bookkeeping guidance Caught by the drift pin added in the same change, on its first run: `STORED_ENVELOPE_KEYS` has SEVEN entries and the guidance table listed six. This is the drift the pin exists for, and it is silent in the direction that matters — `package` is peeled correctly on the stored paths, so every other test stays green, while an author who hand-writes it into a declaration gets the generic "unrecognized key" instead of the wrong-layer prescription. A rejection arriving without its upgrade is the ledger's finding 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M * chore(changeset): answer the ADR-0087 disposition question in writing (#6148) The gate does not decide whether an entry is needed; it requires the question be answered. Answered `not-required (no-migration-prescription)`: this change retires no key, only the unknown-key posture moves, and an undeclared key was never honoured — so no stored shape carries it and nothing that ever worked becomes invalid. The schema rejection is the upgrade channel and is more specific than a ledger line could be. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M * chore(changeset): state the ADR-0087 disposition without rewrite-arrow syntax The marker's own category name supplies the word 'migration', and the two worked examples supplied the arrow — so the disposition line matched the gate's framed-line rewrite-prescription detector and contradicted itself. Same reasoning, stated without FROM/TO arrow syntax; the per-key prescriptions live in the schema's aliases/guidance, which is where an author meets them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd704cc commit eaed61f

11 files changed

Lines changed: 513 additions & 69 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
<!-- adr-0087: not-required (no-migration-prescription) this change retires NO key. The endpoint vocabulary is byte-identical and only the unknown-key POSTURE moves, from strip to reject. Nothing exists for `objectstack migrate meta` to rewrite, because an undeclared key was never honoured in the first place: it was dropped at parse and so never reached storage, the matcher or the executor. No stored shape carries one, and no authored shape that ever WORKED becomes invalid. There is also no single FROM/TO rule a ledger entry could state, 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 a wrong-layer pointer at the surface that really owns the key. The one ADR-0087 entry that DOES govern this surface, `declarative-apis-endpoints-live`, is already registered for protocol 17 and is updated by this PR rather than duplicated. Measured blast radius: 0 affected entries across `examples/*` (2 endpoints) and the `cloud` repo (0 endpoints). -->
6+
7+
`ApiEndpointSchema` rejects undeclared keys (#5384), and the author-state type is named on the upgrade path (#5227)
8+
9+
`api` became a registered metadata type at #5312, which made
10+
`packages/spec/src/api/endpoint.zod.ts` an AUTHORING surface — `defineStack({ apis })`,
11+
the Studio metadata-admin form, and `PUT /meta/api/:name`'s 422 — while it was still a
12+
plain open `z.object`. An undeclared key was therefore dropped on every path: a
13+
`cacheTTL` / `objectParam` / `outputMappings` typo parsed green, published green, and the
14+
endpoint then served without the policy or projection its author wrote. That direction is
15+
fail-safe for `authRequired` alone (an unrecognized spelling leaves the default `true`
16+
standing); it was never fail-safe for the mapping, cache and rate-limit blocks.
17+
18+
The shape is now `strictObject`, so an undeclared key is a named rejection carrying the
19+
surface, the offending key and a rename. Two curated wrong-layer prescriptions ship with
20+
it:
21+
22+
- **`namespace`** — ADR-0121 D2 derives the namespace segment of `path` from
23+
`manifest.namespace`; it has never been per-endpoint, so the rejection points at the
24+
manifest instead of suggesting a rename.
25+
- **the six stored-envelope bookkeeping keys** (`packageId`, `state`, `version`,
26+
`published*`) — written onto the stored ROW by `register` / `publishPackage`, never onto
27+
a declaration.
28+
29+
**The order this landed in is the part worth keeping.** Closing the shape was measured and
30+
REFUSED first: the same schema parsed STORED rows at `buildEndpointIndex` and
31+
`gateApiItemsForPublish`, so a naked `strictObject` failed every row with
32+
`unrecognized_keys: ['packageId', 'state']` — the load-time backstop excluded the endpoint
33+
(404) and the publish gate reported a schema error in place of the ADR-0121 D6 verdict it
34+
exists to give. The debt was real and it was not in this vocabulary, so #5309 (PR #6576)
35+
paid it at the layer that owned it (`peelStoredEnvelope`). `ApiEndpointSchema` never
36+
learned a bookkeeping key.
37+
38+
**Breaking for metadata that was already silently broken.** An `apis:` entry carrying an
39+
undeclared key now fails `objectstack validate`, `objectstack build` and the metadata write
40+
path instead of publishing with the key discarded. Measured before landing: the example
41+
corpus (2 endpoints) and the `cloud` repository (0 endpoints) carry zero undeclared keys,
42+
so nothing in-tree changes verdict.
43+
44+
`api` also leaves the #4001 campaign's `STILL_STRIP` list — closed registered types 24 → 25
45+
of 26, with `view` the only entry left — and the CLI metadata gate's row moves from
46+
`NOT_YET_CLOSED` into `GATED_AT`.
47+
48+
**#5227** — no schema change. `ApiEndpoint` already denotes the AUTHOR state after ADR-0122
49+
phase 2, so omitting `authRequired` compiles; what was missing was anywhere saying so. The
50+
`declarative-apis-endpoints-live` upgrade-guide entry, whose whole safety argument is that
51+
"an omission is SAFE", now carries the type annotation that makes the omission writable:
52+
annotate declarations `ApiEndpoint`, hold parse results as `ApiEndpointParsed`.

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1261,7 +1261,7 @@ rest at #5107.
12611261

12621262
| Dir | Dominant class | Rationale |
12631263
|---|---|---|
1264-
| `api/` | wire | REST/GraphQL request/response contracts — tolerant by design |
1264+
| `api/` | **mixed · `endpoint.zod.ts` authorable, the rest wire** | ⚠️ **Split at #5384 — and the flat `wire` verdict this row used to carry was correct when written and then silently expired, which is the finding.** The rest of the directory is unchanged: REST/GraphQL request/response contracts, tolerant by design. But `endpoint.zod.ts` stopped being one of them at **#5312**, which registered `api` as a metadata type (`DEFAULT_METADATA_TYPE_REGISTRY` / `BUILTIN_METADATA_TYPE_SCHEMAS`) — from that moment `ApiEndpointSchema` was simultaneously an AUTHORING surface (`defineStack({ apis })`, the Studio metadata-admin form, `PUT /meta/api/:name`'s 422) and a wire shape, while this row still told every reader the whole directory was "tolerant by design". A row is read as licence, which is exactly what this ledger's own gate exists to prevent. `ApiEndpointSchema` is now `strictObject` (#5384): an undeclared key on an endpoint is a named rejection carrying the surface, the offending key and a rename, instead of a silent strip that let a `cacheTTL` / `objectParam` / `outputMappings` typo publish green and serve without the policy or projection its author wrote. Two curated wrong-layer pointers ship with it: **`namespace`** (ADR-0121 D2 — the namespace segment of `path` is derived from `manifest.namespace` and has never been per-endpoint; `publish-endpoint-gate.test.ts` pins that the gate does not believe it) and the six **stored-envelope bookkeeping** keys (`packageId`, `state`, `version`, `published*`). ⚠️ **The order is the part worth keeping.** Closing this shape was measured and REFUSED first (2026-08-05, maintainer): the same schema parsed STORED rows at `buildEndpointIndex` and `gateApiItemsForPublish`, so a naked `strictObject` failed every row with `unrecognized_keys: ['packageId', 'state']` — the load-time backstop excluded the endpoint (404) and the publish gate reported a schema error in place of the ADR-0121 D6 verdict it exists to give, 11 tests red in `packages/metadata`. The debt was real and it was NOT in this vocabulary, so it was paid at the layer that owned it: **#5309 (PR #6576)** peeled the envelope off before the body parse (`peelStoredEnvelope`), after which a strict probe left exactly ONE red — a fixture planting an authored `namespace`, re-spelled by #5384 rather than deleted. Teaching `ApiEndpointSchema` two bookkeeping keys to buy strictness would have made the authoring contract describe the storage layer; that trade was refused and did not have to be made. `api` left `STILL_STRIP` (`kernel/metadata-type-schemas.test.ts`) with this change — closed registered types 24 → 25 of 26 — and the CLI gate row moved from `NOT_YET_CLOSED` into `GATED_AT` (`packages/cli/test/metadata-type-schema-gate.test.ts`), the deliberate ratchet step that file's own note asked for. `view` is now the only entry left on `STILL_STRIP` |
12651265
| `system/` | mixed | manifest/datasource blocks are authored; runtime envelopes are wire |
12661266
| `kernel/` | wire | plugin/kernel contracts, code-to-code |
12671267
| `cloud/` | wire | multi-tenant runtime |

0 commit comments

Comments
 (0)