From effb44939d79e81b57d160f1862f510e9feb995f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:06:37 +0000 Subject: [PATCH 1/5] wip(rest): maintenance switch + per-item radius Co-Authored-By: Claude Opus 5 --- packages/rest/src/rest-server.ts | 126 +++++++++++++++++++--- packages/spec/src/api/rest-server.test.ts | 6 +- packages/spec/src/api/rest-server.zod.ts | 56 ++++++++-- 3 files changed, 169 insertions(+), 19 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 67f490fe5b..4d20c874c6 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -857,10 +857,20 @@ type NormalizedRestServerConfig = { * mask entirely (the data plane is unaffected either way). */ maskObjectFields: boolean; + /** + * [#15542 / #15854] One switch per FACE, each gating exactly what its + * name states — `types` the type list, `items` the per-type list, + * `item` the whole per-item face (reads, `PUT`, `DELETE` and the + * history family), `maintenance` the whole-store operations + * (`/diagnostics`, `/_drafts`, `POST /_migrate-stored`). The radius of + * each is pinned route by route in + * `rest-config-mount-table.pin.test.ts`. + */ endpoints: { types: boolean; items: boolean; item: boolean; + maintenance: boolean; }; }; batch: { @@ -4045,6 +4055,12 @@ export class RestServer { types: metadata.endpoints?.types ?? true, items: metadata.endpoints?.items ?? true, item: metadata.endpoints?.item ?? true, + // [#15542] The whole-store family's own switch. Default ON, + // like its three siblings: an embedder who authored only + // `items: false` before keeps `/diagnostics`, `/_drafts` and + // the `POST /_migrate-stored` door, which used to leave with + // that switch — the compatibility cost the ruling priced. + maintenance: metadata.endpoints?.maintenance ?? true, // `schema` is a tombstone since #14691: it gated a route that // does not exist. }, @@ -4896,7 +4912,8 @@ export class RestServer { * local: the two passes share this method, not their routes. * * What actually mounts is gated further by `metadata.endpoints.types` / - * `.items` / `.item` — the routes below are the maximum, not a guarantee. + * `.items` / `.item` / `.maintenance` — the routes below are the maximum, + * not a guarantee. * * Families, in registration order: the type list (`/meta`, and its * `/meta/types` spelling) → whole-store operations (`/diagnostics`, @@ -4906,6 +4923,36 @@ export class RestServer { * `history`, `audit`, `diff`, `publish`, `rollback`, `published`, and the * object FSM read `state/:field`). * + * **[#15542 / #15854] One switch per FAMILY, and the families above are + * exactly the switches.** The taxonomy in the paragraph above predates the + * switch surface by a while, and the two disagreed in both directions: the + * whole-store family rode `endpoints.items` (a switch whose `describe()` + * named the per-type list, so closing a listing read silently disarmed the + * `POST /_migrate-stored` write door), while the per-item family's own + * `PUT`, `DELETE` and history sub-resources rode nothing but + * `api.enableMetadata` (so closing the per-item surface left its writes + * mounted). Now: `types` → the type list; `items` → `/:type` alone; + * `maintenance` → the whole-store family; `item` → the whole per-item + * face, book tree included, reads and writes alike. + * + * Two consequences when adding a route here: + * 1. **Pick its switch deliberately.** A route added inside an existing + * `if` block inherits that block's switch by position alone, which is + * how the drift above accumulated. The per-item family's gate is + * spelled as {@link registerPerItemRoute} at its later members for + * exactly this reason — the gate travels with the registration rather + * than with a brace several hundred lines up. + * 2. **Extend the pin in the same PR.** Every switch's radius is asserted + * route by route in `rest-config-mount-table.pin.test.ts`, in both + * directions, so a new mount reddens it. That redness is the review + * prompt, not an obstacle: add the route to the switch's row. + * + * ⚠️ `GET {metaPath}/object/:name/state/:field` is deliberately in NO + * per-family switch and answers to `api.enableMetadata` alone. It is the + * object FSM read, addressed by object name rather than by `:type/:name`, + * and the ruling that drew these four radii does not name it. Moving it + * under a switch is a decision, not a tidy-up. + * * [#12195] The compound-name twins spelled `/:type/:section/:name` used to * close that list. They are RETIRED (stage 3 of #12176): every item is * addressed through the single-segment `/:type/:name`, with the name @@ -4933,6 +4980,38 @@ export class RestServer { const metaPath = `${basePath}${metadata.prefix}`; const isScoped = basePath.includes('/environments/:environmentId'); + /** + * [#15542 / #15854] Register a route only when the per-item switch — + * `metadata.endpoints.item` — is on. Its radius is the WHOLE per-item + * face: `GET` / `PUT` / `DELETE {metaPath}/:type/:name`, the + * `/references` and `/layers` reads, the history family (`/history`, + * `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and the + * book tree. The first four of those are inside the `if` block further + * down; every later member goes through this call. + * + * A call rather than one more `if` block, for two reasons that are not + * cosmetic: + * 1. **No single brace pair contains exactly the right set.** The + * later members are spread across ~1200 lines with + * `GET {metaPath}/object/:name/state/:field` — deliberately NOT + * part of this face — sitting among them. + * 2. **A gate that travels with its registration cannot be inherited + * or shed by moving a route past a brace**, which is exactly how + * this switch came to gate four reads and none of its own writes: + * `PUT` and `DELETE` were registered below the block's closing + * brace and answered to `api.enableMetadata` alone. + * + * ⚠️ Reads `this.routeManager` at CALL time, deliberately. + * {@link registerMetadataEndpoints} swaps the anonymous-deny wrapping + * registrar in for the duration of this method and restores it in a + * `finally`, so a reference captured at definition time would register + * past that gate. + */ + const registerPerItemRoute = (entry: Parameters[0]): void => { + if (metadata.endpoints.item === false) return; + this.routeManager.register(entry); + }; + // GET /meta - List all metadata types // // Also mounted at `/meta/types`, the spelling the dispatcher's `/meta` @@ -4999,7 +5078,13 @@ export class RestServer { // // Registered BEFORE `/meta/:type` so the `diagnostics` segment // is not captured as a `:type` parameter. - if (metadata.endpoints.items !== false) { + // + // [#15542] First of the three WHOLE-STORE operations, and they share + // one switch of their own — `metadata.endpoints.maintenance`. They + // used to ride `endpoints.items`, whose declared meaning is the + // per-type list, so closing a listing read silently took this sweep, + // `/_drafts` and the `POST /_migrate-stored` write door with it. + if (metadata.endpoints.maintenance !== false) { this.routeManager.register({ method: 'GET', path: `${metaPath}/diagnostics`, @@ -5195,7 +5280,9 @@ export class RestServer { // // Registered BEFORE `/meta/:type` so the `_drafts` segment is not // captured as a `:type` parameter. - if (metadata.endpoints.items !== false) { + // + // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. + if (metadata.endpoints.maintenance !== false) { this.routeManager.register({ method: 'GET', path: `${metaPath}/_drafts`, @@ -5287,7 +5374,12 @@ export class RestServer { // // Registered BEFORE `/meta/:type` so the leading-underscore segment is // not captured as a `:type` parameter (same reason as `_drafts`). - if (metadata.endpoints.items !== false) { + // + // [#15542] Whole-store operation — `metadata.endpoints.maintenance`. + // This is the WRITE door the card was filed about: it used to be + // unmounted by `endpoints.items: false`, a switch declared as "list + // items of type". + if (metadata.endpoints.maintenance !== false) { this.routeManager.register({ method: 'POST', path: `${metaPath}/_migrate-stored`, @@ -5365,6 +5457,9 @@ export class RestServer { } // GET /meta/:type - List items of a type + // + // [#15542] The whole of `metadata.endpoints.items` — this one mount, + // and nothing else, which is what its `describe()` has always said. if (metadata.endpoints.items !== false) { this.routeManager.register({ method: 'GET', @@ -5824,6 +5919,13 @@ export class RestServer { } // GET /meta/:type/:name - Get specific item + // + // [#15542 / #15854] The first four members of the per-item face. The + // rest of it — `PUT`, `DELETE` and the history family — is registered + // below this block's closing brace and goes through + // {@link registerPerItemRoute}, which carries the SAME switch. ⛔ The + // brace is not the radius: read the pin table in + // `rest-config-mount-table.pin.test.ts` for what `item` gates. if (metadata.endpoints.item !== false) { // Phase 3a-references: /meta/:type/:name/references must be // registered BEFORE /meta/:type/:name so the more-specific @@ -6715,7 +6817,7 @@ export class RestServer { // PUT /meta/:type/:name - Save metadata item // We always register this route, but return 501 if protocol doesn't support it // This makes it discoverable even if not implemented - this.routeManager.register({ + registerPerItemRoute({ method: 'PUT', path: `${metaPath}/:type/:name`, handler: async (req: any, res: any) => { @@ -6971,7 +7073,7 @@ export class RestServer { // DELETE /meta/:type/:name - Reset metadata item to artifact default // Removes a customization overlay row from sys_metadata (ADR-0005). // Returns 200 even when no overlay existed (idempotent reset). - this.routeManager.register({ + registerPerItemRoute({ method: 'DELETE', path: `${metaPath}/:type/:name`, handler: async (req: any, res: any) => { @@ -7136,7 +7238,7 @@ export class RestServer { // (view/dashboard/report/email_template) return real events; // non-overlay types return `{ events: [] }` (the legacy raw-engine // path does not record history). - this.routeManager.register({ + registerPerItemRoute({ method: 'GET', path: `${metaPath}/:type/:name/history`, handler: async (req: any, res: any) => { @@ -7271,7 +7373,7 @@ export class RestServer { // 日志 / Audit log" tab can show who tried what and whether // a lock blocked it. Empty array on environments where the // table is not yet provisioned. - this.routeManager.register({ + registerPerItemRoute({ method: 'GET', path: `${metaPath}/:type/:name/audit`, handler: async (req: any, res: any) => { @@ -7396,7 +7498,7 @@ export class RestServer { // POST /meta/:type/:name/publish — promote the pending draft // overlay to live. 404 [no_draft] when nothing to publish. - this.routeManager.register({ + registerPerItemRoute({ method: 'POST', path: `${metaPath}/:type/:name/publish`, handler: async (req: any, res: any) => { @@ -7597,7 +7699,7 @@ export class RestServer { // POST /meta/:type/:name/rollback — restore a historical version // as the new live overlay. Body: { toVersion: , message? }. - this.routeManager.register({ + registerPerItemRoute({ method: 'POST', path: `${metaPath}/:type/:name/rollback`, handler: async (req: any, res: any) => { @@ -7715,7 +7817,7 @@ export class RestServer { // GET /meta/:type/:name/diff?from=N&to=M — structural diff // between two historical versions (or one version vs current). - this.routeManager.register({ + registerPerItemRoute({ method: 'GET', path: `${metaPath}/:type/:name/diff`, handler: async (req: any, res: any) => { @@ -7919,7 +8021,7 @@ export class RestServer { // was removed WITHOUT removing the capability — D1's "any stored junk // name remains listable and clearable" still holds through this door. { - this.routeManager.register({ + registerPerItemRoute({ method: 'GET', path: `${metaPath}/:type/:name/published`, handler: async (req: any, res: any) => { diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index 370d4fd9e5..d83ec5039a 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -290,7 +290,7 @@ describe('MetadataEndpointsConfigSchema', () => { } }); - it('should accept endpoints config — the three switches that gate real mounts', () => { + it('should accept endpoints config — the four switches that gate real mounts', () => { const config = MetadataEndpointsConfigSchema.parse({ endpoints: { types: true, @@ -301,6 +301,10 @@ describe('MetadataEndpointsConfigSchema', () => { expect(config.endpoints?.item).toBe(false); expect(config.endpoints).not.toHaveProperty('schema'); + // [#15542] The whole-store family's own switch defaults on, so an author + // who wrote the three pre-existing keys keeps `/diagnostics`, `/_drafts` + // and the `POST /_migrate-stored` door — they used to ride `items`. + expect(config.endpoints?.maintenance).toBe(true); }); it('[#14691] REJECTS `endpoints.schema` — it gated a route that does not exist', () => { diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 55530a2524..879e7d01b9 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -283,8 +283,9 @@ export type CrudEndpointsConfigParsed = z.infer z.object({ /** * Enable specific metadata endpoints + * + * **Every switch here gates exactly the face its name states, reads and + * writes alike, and its `describe()` enumerates every mount it takes.** The + * radius IS the contract, not a summary of it: a reader who turns a switch + * off is entitled to know what leaves with it. + * + * That was not true before #15542 / #15854, and both directions of the + * mismatch were live at once. `items` said "list items of type" and also + * gated the whole-store family — the cross-type diagnostics sweep, the draft + * list, and the `POST /meta/_migrate-stored` **write door** — so closing a + * listing read silently disarmed a migration door. `item` said "get specific + * item" and gated four reads while leaving its own `PUT` and `DELETE` and + * the whole history family answering to `api.enableMetadata` alone, so + * closing the per-item surface left its writes mounted. The whole-store + * family now has its own key, `maintenance`, and `item` covers the per-item + * writes its name has always promised. + * + * ⚠️ The `/meta` paths named below are the DEFAULT prefix; every one of them + * moves with `prefix` above, and the environment-scoped base mounts a second + * copy of the same table. + * + * `api.enableMetadata` stays the master switch above all four: `false` + * removes the entire metadata surface whatever these say. + * + * ⛔ Each key's mount radius is pinned route by route in + * `packages/rest/src/rest-config-mount-table.pin.test.ts` (the #15544 + * shape — it asserts the route is ABSENT from the mounted table, not what + * the switch normalizes to). A gate that grows or loses a route reddens + * there. Move a radius and move that table in the same PR; ⛔ never relax it + * to match a drifted gate. */ endpoints: z.object({ - types: z.boolean().default(true).describe('GET /meta - List all metadata types'), - items: z.boolean().default(true).describe('GET /meta/:type - List items of type'), - item: z.boolean().default(true).describe('GET /meta/:type/:name - Get specific item'), + types: z.boolean().default(true) + .describe('Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths)'), + items: z.boolean().default(true) + .describe('Mount the per-type item list — `GET /meta/:type`, and nothing else'), + item: z.boolean().default(true) + .describe( + 'Mount the whole per-item face — `GET`, `PUT` and `DELETE /meta/:type/:name`, its ' + + '`/references` and `/layers` reads, the history family (`/history`, `/audit`, `/diff`, ' + + '`/published`, `/publish`, `/rollback`) and `GET /meta/book/:name/tree`', + ), + maintenance: z.boolean().default(true) + .describe( + 'Mount the whole-store maintenance operations — `GET /meta/diagnostics`, ' + + '`GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door', + ), /** * [REMOVED in #14691] Gated a route that does not exist: the REST server * mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and @@ -354,7 +397,8 @@ export const MetadataEndpointsConfigSchema = lazySchema(() => z.object({ '`metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove) — it gated a route that does not exist: the REST server mounts no ' + '`GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete ' - + 'the key; `endpoints.types` / `items` / `item` are the switches that gate real mounts.', + + 'the key; `endpoints.types` / `items` / `item` / `maintenance` are the switches that gate real ' + + 'mounts.', ), }).optional().describe('Enable/disable specific endpoints'), })); From f521dd737f2c9b3e3677484a4be7011c3072a087 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:10:36 +0000 Subject: [PATCH 2/5] wip: ledger, pins, checklist, changeset Co-Authored-By: Claude Opus 5 --- ...endpoints-switch-radius-maintenance-key.md | 82 +++++++++++++++++++ docs/qa/platform-checklist/FOLLOW-UPS.md | 4 +- .../platform-checklist/areas/api-backend.json | 79 +++++++++++++----- .../src/rest-config-mount-table.pin.test.ts | 73 +++++++++++++---- .../rest-sub-config-parse-not-cast.test.ts | 12 ++- .../spec/liveness/metadata_endpoints.json | 18 ++-- 6 files changed, 219 insertions(+), 49 deletions(-) create mode 100644 .changeset/metadata-endpoints-switch-radius-maintenance-key.md diff --git a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md new file mode 100644 index 0000000000..c38194c13a --- /dev/null +++ b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854) + +`RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming +exactly one route, and each gated a different set. The mismatch ran in **both** +directions at once: + +- **`items`** — declared "GET /meta/:type - List items of type" — also gated the + whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the + draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**. + An operator who switched off a listing read they considered chatty silently unmounted + a migration door. +- **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four + *reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the + per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family + (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to + `api.enableMetadata` alone. An operator who closed the per-item surface left its + writes mounted. + +Neither is a liveness defect — all three keys were genuinely read — which is why no +ADR-0049 census could ever flag them: what drifted was each key's **radius** against its +own documentation. + +**One principle now holds across the block: a switch gates exactly the face its name +states, reads and writes alike.** + +| key | mounts it gates (default prefix `/meta`) | +|---|---| +| `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) | +| `items` | `GET /meta/:type` — and nothing else | +| `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` | +| `maintenance` | **new** — `GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` | + +All four `describe()` strings are rewritten to enumerate what they gate, so the +generated reference page is the radius rather than a sample of it. +`api.enableMetadata` remains the master switch above all four, and +`GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name +rather than by `:type/:name` — deliberately stays under that master switch alone. + +**BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`, +the mounted route table moves for two of the four keys, in opposite directions: + +- **`items: false` now removes one route instead of four.** An embedder relying on it to + close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all + three** unless it also sets `maintenance: false`. That is a write door coming back, so + it is the half to read twice. One line restores the old table: + `endpoints: { items: false, maintenance: false }`. +- **`item: false` now removes twelve routes instead of four.** An embedder relying on it + to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family + mounted **loses those eight**. There is no key that restores them — the per-item face is + one face by this ruling — so an embedder that wants the writes keeps `item` on and + closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`. + +Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no +shipped boot path** today (`os serve` fixes the config and the dev plugin passes none, +#15543), so the measured population of affected authors is **zero** and the blast radius +is programmatic embedders only. That is precisely why this lands now — once a boot path +starts authoring the config, the same change becomes a behaviour change on live +operators. + +**ADR-0087 disposition: no D2 conversion entry and no D3 semantic migration.** No +authored key changes shape or spelling — `items: false` still parses to `items: false`, +`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema` +stays the #14691 tombstone it already was). There is nothing for the conversion layer to +convert and nothing for `migrate meta` to replay: a `RestServerConfig` is plugin TS +configuration, never a stack collection member and never a `sys_metadata` row (the +`RestServerConfig.openApi31` precedent, #4579), so no rehydration seam sees it. What +changes is a mounted route table at construction time, which is what the **BREAKING** +paragraph above is for and what the mount-table pin enforces. + +`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where +the gates live, so the route-table change is observable there and not only in the +declaration. + +Every key's radius is pinned route by route, in both directions, in +`packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts +each route is **absent from the mounted table** when its switch is off rather than what +the switch normalizes to. A gate that grows or loses a route reddens there. diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 85c3a20bdc..bdfcbdecbe 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -567,9 +567,9 @@ card filed against it*. Recorded, not acted on — the channel question is #1173 | # | finding | evidence | captured in | handling | |---|---|---|---|---| -| E1 | **`metadata.endpoints.items` gates four routes, three of which its declared meaning does not cover** — its `describe()` says "GET /meta/:type — List items of type", and it also gates `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the **`POST {prefix}/_migrate-stored` write door**. An operator switching off a listing read silently disarms a migration door and the cross-type spec-validation sweep. `endpoints.item` is milder but the same shape: it also takes `{prefix}/book/:name/tree`. | `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` (four `endpoints.items` gates, four `endpoints.item` gates) vs `packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema` (one route named per switch) | api-backend.rest-metadata-config-contract (a clause requires the run to ENUMERATE each switch's real radius) | design/docs — filed as #15542 | +| E1 | **`metadata.endpoints.items` gates four routes, three of which its declared meaning does not cover** — its `describe()` says "GET /meta/:type — List items of type", and it also gates `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the **`POST {prefix}/_migrate-stored` write door**. An operator switching off a listing read silently disarms a migration door and the cross-type spec-validation sweep. `endpoints.item` is milder but the same shape: it also takes `{prefix}/book/:name/tree`. | `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` (four `endpoints.items` gates, four `endpoints.item` gates) vs `packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema` (one route named per switch) | api-backend.rest-metadata-config-contract (a clause requires the run to ENUMERATE each switch's real radius) | design/docs — filed as #15542, **RULED and closed**: every `endpoints.*` switch now gates exactly the face its name states. The whole-store family (`/diagnostics`, `/_drafts`, `POST {prefix}/_migrate-stored`) moved to a new key `maintenance`; `items` is down to its one declared mount; `item` gained the per-item `PUT`/`DELETE` and the history family it never gated (the converse mismatch, filed as #15854 and landed in the same PR); all four `describe()` strings now enumerate their mounts. ⛔ The checklist clause is **kept**, not retired — the run still ENUMERATES each switch's real radius from a route-table diff, and `areas/api-backend.json` revision 2 carries the new expected sets. | | E2 | **No shipped boot path authors `RestServerConfig` at all.** `os serve` constructs the REST plugin with a fixed config (only `enableProjectScoping` / `projectResolution` are threaded) and the dev plugin calls `createRestApiPlugin()` with none, so `crud` / `metadata` / `batch` / `routes` are reachable only from embedder code (`createRestApiPlugin({ api })`, `createHonoServerPlugin({ restConfig })`). A deployment cannot set `batch.maxBatchSize`, move `crud.dataPrefix`, or opt out of ADR-0106 D8 masking without embedding. | `packages/cli/src/commands/serve.ts` (the fixed construction) · `packages/plugins/plugin-dev/src/dev-plugin.ts` (no config) | the three config items' `knownGaps` — every non-default clause is scored `oracle: test` in a harness, and the run record must say so instead of claiming a reconfigured deployment | capability gap — filed as #15543 | -| E3 | **The MOUNT half of every sub-config switch is unpinned.** `packages/rest/src/rest-sub-config-parse-not-cast.test.ts` pins what a switch normalizes to, and `rest-batch-size-cap.test.ts` pins the cap's effect; nothing asserts that a `false` switch removes its route from the table `getRoutes()` returns. The declared-not-enforced direction — a switch that normalizes correctly and gates nothing — is exactly what no current test would catch. ⚠️ The card said **nine** switches; re-measured on `cc5b3dd0c27` the mount-gating population is **nineteen** — the twelve sub-config switches the card enumerates (its own list adds to twelve, not nine) plus the seven `api.enable*` gates in `registerRoutes`, which are the same seam and were equally unpinned. | the two test files above; the gates live in `registerCrudEndpoints` / `registerBatchEndpoints` / `registerMetadataEndpointsInner` / `registerRoutes` | the three config items (the mount clauses, each with the gap named in `knownGaps`) | test gap — filed as #15544, **closed by `packages/rest/src/rest-config-mount-table.pin.test.ts`**: all nineteen gates pinned as a set difference against the all-true baseline, each with its presence twin. ⚠️ The three config items' `knownGaps` still say the harness is the only observation — stale in the good direction, refresh pending (`areas/api-backend.json` was held by another branch when this landed). | +| E3 | **The MOUNT half of every sub-config switch is unpinned.** `packages/rest/src/rest-sub-config-parse-not-cast.test.ts` pins what a switch normalizes to, and `rest-batch-size-cap.test.ts` pins the cap's effect; nothing asserts that a `false` switch removes its route from the table `getRoutes()` returns. The declared-not-enforced direction — a switch that normalizes correctly and gates nothing — is exactly what no current test would catch. ⚠️ The card said **nine** switches; re-measured on `cc5b3dd0c27` the mount-gating population is **nineteen** — the twelve sub-config switches the card enumerates (its own list adds to twelve, not nine) plus the seven `api.enable*` gates in `registerRoutes`, which are the same seam and were equally unpinned. | the two test files above; the gates live in `registerCrudEndpoints` / `registerBatchEndpoints` / `registerMetadataEndpointsInner` / `registerRoutes` | the three config items (the mount clauses, each with the gap named in `knownGaps`) | test gap — filed as #15544, **closed by `packages/rest/src/rest-config-mount-table.pin.test.ts`**: all nineteen gates pinned as a set difference against the all-true baseline, each with its presence twin (**twenty** since #15542 added `metadata.endpoints.maintenance`; the pin's §0 count moves deliberately with each switch added or retired). ⚠️ The three config items' `knownGaps` still say the harness is the only observation — stale in the good direction, refresh pending (`areas/api-backend.json` was held by another branch when this landed); `rest-metadata-config-contract`'s `automated.ref` now names this pin, the other two are still owed. | ### 10c. Checked and CLEAN (so the next sweep does not re-derive) diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 3212d62015..f9f3e50ab5 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -87,7 +87,12 @@ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-08-18", "change": "corrected three separate wrong shapes, each of which made correct behaviour read as a defect. (a) capabilities.transactionalBatch is a CapabilityDescriptor object on the wire, so the clause's literal == true is always false — only the @objectstack/client getter flattens it to a boolean. (b) Steps 2-3 sent atomic inside an options wrapper; CrossObjectBatchRequestSchema declares a TOP-LEVEL atomic and no options, so Zod strips the wrapper and atomic defaults to true — the atomic:false probe therefore succeeds instead of answering BATCH_NOT_ATOMIC. (c) Clause 3 attached the #4793 per-row codes to the cross-object door, whose response schema is { results, droppedFields? } with no per-row error envelope; those codes live on the per-object door (#9417)", "ref": "#9386" } + { + "revision": 3, + "date": "2026-08-18", + "change": "corrected three separate wrong shapes, each of which made correct behaviour read as a defect. (a) capabilities.transactionalBatch is a CapabilityDescriptor object on the wire, so the clause's literal == true is always false — only the @objectstack/client getter flattens it to a boolean. (b) Steps 2-3 sent atomic inside an options wrapper; CrossObjectBatchRequestSchema declares a TOP-LEVEL atomic and no options, so Zod strips the wrapper and atomic defaults to true — the atomic:false probe therefore succeeds instead of answering BATCH_NOT_ATOMIC. (c) Clause 3 attached the #4793 per-row codes to the cross-object door, whose response schema is { results, droppedFields? } with no per-row error envelope; those codes live on the per-object door (#9417)", + "ref": "#9386" + } ] }, { @@ -433,7 +438,12 @@ "change": "new — query-contract matrix over the spec operator vocabulary with known-answer checks, per the deep-test contract", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-18", "change": "re-pointed automated.ref off a source module. packages/objectql/src/filter-comparand-shape.ts is not a test — it exports invalidFilterError / assertListComparandShapes / assertFilterIsMaterializable and declares no describe block; the #5869 gate is actually pinned by engine-filter-array-lowering.test.ts. Also recorded the pin's real reach, since it spans one clause of nine and a pass read off it would be a false green (#9401)", "ref": "#9386" } + { + "revision": 2, + "date": "2026-08-18", + "change": "re-pointed automated.ref off a source module. packages/objectql/src/filter-comparand-shape.ts is not a test — it exports invalidFilterError / assertListComparandShapes / assertFilterIsMaterializable and declares no describe block; the #5869 gate is actually pinned by engine-filter-array-lowering.test.ts. Also recorded the pin's real reach, since it spans one clause of nine and a pass read off it would be a false green (#9401)", + "ref": "#9386" + } ] }, { @@ -533,7 +543,12 @@ "change": "new — error-envelope conformance sampling grounded in the two-tier code ledger, per the deep-test contract", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-18", "change": "re-pointed clause 1 and steps 8/9 at ErrorCode, the canonical ADR-0112 D4 union export. The item asked the runner to hand-union StandardErrorCode with ERROR_CODE_LEDGER, but the latter is a Record of package name to code array rather than a flat code list, and three of the item's own sampled codes (VALIDATION_FAILED, UNSUPPORTED_QUERY_PARAM, BATCH_NOT_ATOMIC) are not in StandardErrorCode at all — they resolve only through REGISTERED_ERROR_CODES. error-code-ledger.zod.ts already exports ErrorCode as exactly that union, so the clause now names one export instead of prescribing a union the runner has to rebuild (#9417)", "ref": "#9386" }, + { + "revision": 2, + "date": "2026-08-18", + "change": "re-pointed clause 1 and steps 8/9 at ErrorCode, the canonical ADR-0112 D4 union export. The item asked the runner to hand-union StandardErrorCode with ERROR_CODE_LEDGER, but the latter is a Record of package name to code array rather than a flat code list, and three of the item's own sampled codes (VALIDATION_FAILED, UNSUPPORTED_QUERY_PARAM, BATCH_NOT_ATOMIC) are not in StandardErrorCode at all — they resolve only through REGISTERED_ERROR_CODES. error-code-ledger.zod.ts already exports ErrorCode as exactly that union, so the clause now names one export instead of prescribing a union the runner has to rebuild (#9417)", + "ref": "#9386" + }, { "revision": 3, "date": "2026-08-21", @@ -1085,7 +1100,9 @@ "revision": 2, "priority": "P1", "surface": "api", - "personas": ["seeded admin"], + "personas": [ + "seeded admin" + ], "fixtures": { "app": "showcase", "requires": [ @@ -1155,7 +1172,9 @@ "export": "ACCEPTED_FILTER_COMPARAND_TYPES", "expect": 6 }, - "traps": ["silent-coercion"], + "traps": [ + "silent-coercion" + ], "source": [ "packages/spec/src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES (ACCEPTED_FILTER_COMPARAND_TYPES, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, FILTER_COMPARAND_BIGINT_EXACT_LIMIT, isAcceptedFilterComparand, normalizeFilterComparandTypes)", "packages/spec/src/data/filter-comparand-shape.ts", @@ -1189,7 +1208,9 @@ "revision": 1, "priority": "P2", "surface": "api", - "personas": ["seeded admin"], + "personas": [ + "seeded admin" + ], "fixtures": { "app": "showcase", "requires": [ @@ -1259,7 +1280,10 @@ "export": "DATE_RANGE_PRESETS", "expect": 13 }, - "traps": ["clock-skew", "timezone-boundary"], + "traps": [ + "clock-skew", + "timezone-boundary" + ], "source": [ "packages/spec/src/data/date-range-presets.ts#DATE_RANGE_PRESETS (DATE_RANGE_PRESETS, DATE_RANGE_PRESET_MACRO_WINDOWS, isDateRangePresetName, bareDateRangePresetComparandMessage)", "packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts", @@ -1283,7 +1307,10 @@ "revision": 1, "priority": "P1", "surface": "build", - "personas": ["seeded admin", "build-time author (no session)"], + "personas": [ + "seeded admin", + "build-time author (no session)" + ], "fixtures": { "app": "showcase", "requires": [ @@ -2163,10 +2190,10 @@ }, { "id": "api-backend.rest-metadata-config-contract", - "title": "RestServerConfig.metadata is the metadata surface's construction contract: prefix moves eleven mounts and their discovery advertisement, three endpoint switches gate more routes than their names suggest, maskObjectFields is the ADR-0106 D8 disclosure gate, and both tombstones refuse at construction", + "title": "RestServerConfig.metadata is the metadata surface's construction contract: prefix moves eleven mounts and their discovery advertisement, four endpoint switches each gate exactly the face its name states (#15542/#15854), maskObjectFields is the ADR-0106 D8 disclosure gate, and both tombstones refuse at construction", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -2192,8 +2219,9 @@ "as the restricted persona GET /api/v1/meta/object/showcase_account and diff the served fields against the admin's copy of the same document", "harness: construct with { metadata: { prefix: '/metadata' } } and confirm every metadata mount re-bases and the discovery body advertises the new prefix", "harness: construct with { metadata: { endpoints: { types: false } } } — GET {prefix} and GET {prefix}/types both disappear (one handler, two paths)", - "harness: construct with { metadata: { endpoints: { items: false } } } and enumerate what leaves: GET {prefix}/:type AND {prefix}/diagnostics AND {prefix}/_drafts AND the POST {prefix}/_migrate-stored write door — four mounts, not the one its describe() names", - "harness: construct with { metadata: { endpoints: { item: false } } } and enumerate: GET {prefix}/:type/:name, /:type/:name/references, /:type/:name/layers and {prefix}/book/:name/tree", + "harness: construct with { metadata: { endpoints: { items: false } } } and enumerate what leaves: GET {prefix}/:type and NOTHING else — exactly the one mount its describe() names (#15542 moved the whole-store family off this switch; a run that still sees /diagnostics, /_drafts or POST /_migrate-stored leave with it is the old radius returning)", + "harness: construct with { metadata: { endpoints: { maintenance: false } } } and enumerate: GET {prefix}/diagnostics, GET {prefix}/_drafts and the POST {prefix}/_migrate-stored write door — three whole-store operations, the key #15542 created so that closing a listing read stops disarming a migration door", + "harness: construct with { metadata: { endpoints: { item: false } } } and enumerate: the WHOLE per-item face — GET, PUT and DELETE {prefix}/:type/:name, /:type/:name/references, /:type/:name/layers, the history family (/history, /audit, /diff, /published, /publish, /rollback) and {prefix}/book/:name/tree; twelve mounts, writes included (#15854)", "harness: construct with { metadata: { enableCache: false } } and confirm the item read takes the uncached branch; then with enableCache true against an app-type, a dashboard-type, a draft read, a preview-drafts read, a package-scoped read and an audience-gated type — each is a carve-out that bypasses the cached path even when the switch is on", "harness: construct with { metadata: { maskObjectFields: false } }; separately construct with the default and metaType != 'object'", "harness: construct with { metadata: { cacheTtl: 3600 } }, then 0, then -1, then { metadata: { endpoints: { schema: false } } }; capture each refusal text" @@ -2212,10 +2240,10 @@ "evidence": "route table + discovery body at the non-default prefix" }, { - "clause": "the three endpoint switches gate MORE than their describe() strings say, and the run must enumerate what each removes: `types` takes two paths (one handler at {prefix} and {prefix}/types), `items` takes four INCLUDING the POST {prefix}/_migrate-stored write door and the diagnostics sweep, `item` takes four including the book-tree read", + "clause": "each of the FOUR endpoint switches gates exactly the face its name states, and the run must ENUMERATE what each removes rather than trust the describe(): `types` takes two paths (one handler at {prefix} and {prefix}/types), `items` takes exactly one ({prefix}/:type), `maintenance` takes the three whole-store operations INCLUDING the POST {prefix}/_migrate-stored write door, `item` takes the whole per-item face (twelve mounts, PUT and DELETE and the history family included)", "oracle": "test", - "verify": "per-switch route-table diffs against the all-true baseline; the diff sets must be exactly the routes enumerated in the steps. An operator reading `items` as 'list items of type' would silently disarm a migration door — the run records the real radius whatever it finds", - "evidence": "the three route-table diffs" + "verify": "per-switch route-table diffs against the all-true baseline; the diff sets must be exactly the routes enumerated in the steps, in both directions — a switch that GROWS a route is as much a FAIL as one that loses one. ⛔ The run records the real radius whatever it finds: the describe() strings now claim to be exhaustive (#15542/#15854), so a diff that disagrees with them is a defect in the code or in the declaration, never a reason to relax this clause", + "evidence": "the four route-table diffs" }, { "clause": "`maskObjectFields` defaults ON and is a real disclosure gate: a caller who cannot read a field gets a served object schema without that field at all — not its name, label, type, options, formula, visibleWhen, defaultValue or requiredPermissions — and `false` serves the full schema to every authenticated caller (ADR-0106 D8)", @@ -2244,8 +2272,9 @@ "variants": [ "knob:prefix", "switch:endpoints.types (2 mounts)", - "switch:endpoints.items (4 mounts)", - "switch:endpoints.item (4 mounts)", + "switch:endpoints.items (1 mount)", + "switch:endpoints.item (12 mounts)", + "switch:endpoints.maintenance (3 mounts)", "switch:enableCache (+6 carve-outs)", "switch:maskObjectFields", "tombstone:cacheTtl", @@ -2259,13 +2288,13 @@ ], "automated": { "kind": "unit", - "ref": "packages/rest/src/rest-sub-config-parse-not-cast.test.ts (§D pins prefix/enableCache/maskObjectFields survival and the partial endpoints shape; §E pins cacheTtl and endpoints.schema); the mount-table, masking-effect and cache-branch halves have no pin" + "ref": "packages/rest/src/rest-sub-config-parse-not-cast.test.ts (§D pins prefix/enableCache/maskObjectFields survival and the partial endpoints shape; §E pins cacheTtl and endpoints.schema); packages/rest/src/rest-config-mount-table.pin.test.ts (#15544 — the MOUNT half: every switch's radius asserted route by route as ABSENCE from the mounted table, in both directions, including all four endpoints.* rows); the masking-effect and cache-branch halves have no pin" }, "source": [ "packages/spec/liveness/metadata_endpoints.json (the ADR-0049 ledger this item answers: prefix, enableCache, maskObjectFields and three endpoints.* live, cacheTtl and endpoints.schema dead)", - "packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema (the declared contract, the ADR-0106 D8 docblock and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath and the three endpoint gates with their real radius), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (registerDiscoveryEndpoints — discovery.routes.metadata built from prefix), (getRoutes)", - "ADR-0106 D8 (per-caller field masking of served object schemas) · #14691 (the two retirements) · #7526 (the /meta/types ordering fix the `types` switch takes with it) · ADR-0076 D12", + "packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema (the declared contract — four endpoints.* switches whose describe() strings enumerate every mount they gate since #15542/#15854, the ADR-0106 D8 docblock and the two retiredKey tombstones)", + "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the four endpoint gates and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (registerDiscoveryEndpoints — discovery.routes.metadata built from prefix), (getRoutes)", + "ADR-0106 D8 (per-caller field masking of served object schemas) · #14691 (the two retirements) · #7526 (the /meta/types ordering fix the `types` switch takes with it) · ADR-0076 D12 · #15542 / #15854 (the switch-radius ruling that created `maintenance` and gave `item` its own writes) · #15543 (why the measured blast radius of that move is zero: no shipped boot path authors a RestServerConfig)", "sibling clause: platform-core.metadata-registry-serving (it drives the served REGISTRY on the default config; this item covers the config that decides what is served and to whom) · platform-core.docs-audience-gate (the audience-gated types that are a cache carve-out here)" ], "history": [ @@ -2274,6 +2303,12 @@ "date": "2026-09-04", "change": "new — `metadata_endpoints` was UNCLASSIFIED in coverage.json: metadata-registry-serving drove /meta on a default boot and nothing covered the sub-object that decides the prefix, which endpoints exist, whether the cached read path is taken, or whether served object schemas are masked. The switch radii were enumerated from registerMetadataEndpointsInner rather than from the schema's describe() strings, which name one route each and understate three of them — `endpoints.items` also gates the POST _migrate-stored write door", "ref": "#14961" + }, + { + "revision": 2, + "date": "2026-09-06", + "change": "the radii this item enumerates MOVED, by ruling rather than by drift (#15542 comment 5557095147): every endpoints.* switch now gates exactly the face its name states. The whole-store operations (/diagnostics, /_drafts, POST /_migrate-stored) left `items` for a new key `maintenance`; `items` is down to its one declared mount; `item` gained the per-item PUT/DELETE and the history family it never gated. The acceptance clause is kept SATISFIABLE and non-vacuous exactly as the ruling requires — it still demands the run enumerate each switch's real radius from a route-table diff, and the new radii are what it enumerates; what changed is the expected sets, not the obligation to measure them", + "ref": "#15542" } ] }, @@ -2375,4 +2410,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/packages/rest/src/rest-config-mount-table.pin.test.ts b/packages/rest/src/rest-config-mount-table.pin.test.ts index 54ae6d24b0..ad88a9eb92 100644 --- a/packages/rest/src/rest-config-mount-table.pin.test.ts +++ b/packages/rest/src/rest-config-mount-table.pin.test.ts @@ -18,9 +18,20 @@ * * ⛔ This file pins CURRENT mount behaviour. It is not a judgement that each * switch's radius is the right one — where a radius disagrees with the - * switch's own `describe()` that is a defect filed elsewhere (#15542 for - * `metadata.endpoints.items`), and the table below records the radius as - * MEASURED so such a defect is visible here rather than hidden. + * switch's own `describe()` that is a defect filed elsewhere, and the table + * below records the radius as MEASURED so such a defect is visible here rather + * than hidden. + * + * That is exactly how it earned its keep. The two disagreements this file + * recorded on arrival — `metadata.endpoints.items` gating the whole-store + * family including a write door (#15542), `metadata.endpoints.item` gating + * four reads and none of its own writes (#15854) — were ruled and RESOLVED, + * and the rows below are re-stated on the new radii rather than deleted + * (#15542 ruling item 5): the whole-store family answers to its own key, + * `metadata.endpoints.maintenance`, `items` gates one mount, and `item` gates + * the whole per-item face. ⛔ Do not thin these rows back to one route per + * switch "because the describe() now says so" — the describe() being right is + * a claim, and this table is the measurement that keeps it true. * * ## What is pinned, and why it is a diff rather than an existence check * @@ -32,8 +43,12 @@ * `POST {dataPrefix}/:object/query`. The query door has no switch of its * own, so a pin asserting "the list route disappeared" passes while half * the intent is broken. - * - `metadata.endpoints.items` gates FOUR, one of them the write door - * `POST {prefix}/_migrate-stored`, while its `describe()` names one read. + * - `metadata.endpoints.item` gates TWELVE — the per-item reads, `PUT`, + * `DELETE` and the whole history family. A pin that only watched + * `GET {prefix}/:type/:name` would sit green through eleven of them + * moving, which is the state #15854 measured. + * - `metadata.endpoints.maintenance` gates THREE, one of them the write + * door `POST {prefix}/_migrate-stored`. * * Asserting the difference is EXACTLY a named set catches a gate that grows a * route as loudly as one that loses a route. @@ -116,7 +131,7 @@ const ALL_TRUE = { }, crud: { operations: { create: true, read: true, update: true, delete: true, list: true } }, batch: { enableBatchEndpoint: true, operations: { createMany: true, updateMany: true, deleteMany: true } }, - metadata: { endpoints: { types: true, items: true, item: true } }, + metadata: { endpoints: { types: true, items: true, item: true, maintenance: true } }, }; /** Deep-merge just enough to flip one leaf switch off inside ALL_TRUE. */ @@ -167,24 +182,45 @@ const CASES: Array<{ path: string; removes: string[] }> = [ // --- metadata.endpoints.* ---------------------------------------------- // Two spellings, one handler. { path: 'metadata.endpoints.types', removes: [`GET ${META}`, `GET ${META}/types`] }, - // ⚠️ FOUR routes, and one of them is a WRITE door (#15542): the declared - // meaning is "GET /meta/:type - List items of type", but switching it off - // also disarms `_migrate-stored`, `_drafts` and `diagnostics`. + // [#15542] ONE route — the per-type list its `describe()` names, and + // nothing else. It used to take three whole-store routes with it, + // `POST {prefix}/_migrate-stored` among them; those are `maintenance`'s + // now. ⛔ A fourth route reappearing in this row is the old defect + // returning, not a table that needs widening. + { path: 'metadata.endpoints.items', removes: [`GET ${META}/:type`] }, + // [#15542] THREE whole-store operations, one of them a WRITE door. The key + // the ruling created so that closing a listing read stops disarming a + // migration door. { - path: 'metadata.endpoints.items', - removes: [`GET ${META}/:type`, `GET ${META}/_drafts`, `GET ${META}/diagnostics`, `POST ${META}/_migrate-stored`], + path: 'metadata.endpoints.maintenance', + removes: [`GET ${META}/_drafts`, `GET ${META}/diagnostics`, `POST ${META}/_migrate-stored`], }, - // ⚠️ FOUR routes, and NOT the ones a reader would guess: the per-item - // WRITES (`PUT`/`DELETE {prefix}/:type/:name`) and the history family - // (`history`, `audit`, `diff`, `published`, `publish`, `rollback`) are NOT - // gated by it — they answer to `api.enableMetadata` alone. + // [#15854] TWELVE routes — the WHOLE per-item face, which is what the + // switch's name has always promised: the reads, the per-item WRITES + // (`PUT` / `DELETE {prefix}/:type/:name`) and the history family + // (`history`, `audit`, `diff`, `published`, `publish`, `rollback`). Before + // the ruling it gated the four reads alone and the rest answered to + // `api.enableMetadata`. + // + // ⚠️ `GET {prefix}/object/:name/state/:field` is deliberately NOT here. + // It is the object FSM read, addressed by object name rather than by + // `:type/:name`, and no per-family switch gates it — see the + // `api.enableMetadata` row, which is the only one that removes it. { path: 'metadata.endpoints.item', removes: [ + `DELETE ${META}/:type/:name`, `GET ${META}/:type/:name`, + `GET ${META}/:type/:name/audit`, + `GET ${META}/:type/:name/diff`, + `GET ${META}/:type/:name/history`, `GET ${META}/:type/:name/layers`, + `GET ${META}/:type/:name/published`, `GET ${META}/:type/:name/references`, `GET ${META}/book/:name/tree`, + `POST ${META}/:type/:name/publish`, + `POST ${META}/:type/:name/rollback`, + `PUT ${META}/:type/:name`, ], }, @@ -238,9 +274,10 @@ describe('[#15544] §0 the harness measures something', () => { it('the case table is exhaustive at its measured size', () => { // ⛔ A table-driven pin that silently iterates zero cases is the // failure this number exists to prevent. Nineteen mount-gating - // switches were measured on `origin/main` `cc5b3dd0c27`. A switch - // retired or added moves this number DELIBERATELY, with its row. - expect(CASES.length).toBe(19); + // switches were measured on `origin/main` `cc5b3dd0c27`; #15542 added + // `metadata.endpoints.maintenance`, making TWENTY. A switch retired or + // added moves this number DELIBERATELY, with its row. + expect(CASES.length).toBe(20); expect(new Set(CASES.map((c) => c.path)).size).toBe(CASES.length); expect(CASES.every((c) => c.removes.length > 0)).toBe(true); }); diff --git a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts index 9bdb960284..0ea4c1be57 100644 --- a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts @@ -104,7 +104,7 @@ type NormalizedView = { prefix: string; enableCache: boolean; maskObjectFields: boolean; - endpoints: Record<'types' | 'items' | 'item', boolean>; + endpoints: Record<'types' | 'items' | 'item' | 'maintenance', boolean>; }; batch: { maxBatchSize: number; @@ -304,8 +304,16 @@ describe('[#11984] §D the four siblings consume the parsed output', () => { expect(normalized({ batch: { operations: { deleteMany: false } } }).batch.operations).toEqual({ createMany: true, updateMany: true, deleteMany: false, }); + // [#15542] `maintenance` joined the block as the whole-store family's + // own switch, so a partial `endpoints` now fills FOUR defaults. An + // author who wrote only the three older keys keeps `/diagnostics`, + // `/_drafts` and the `POST /_migrate-stored` door, which used to leave + // with `items: false` — the compatibility cost the ruling priced. expect(normalized({ metadata: { endpoints: { item: false } } }).metadata.endpoints).toEqual({ - types: true, items: true, item: false, + types: true, items: true, item: false, maintenance: true, + }); + expect(normalized({ metadata: { endpoints: { maintenance: false } } }).metadata.endpoints).toEqual({ + types: true, items: true, item: true, maintenance: false, }); }); diff --git a/packages/spec/liveness/metadata_endpoints.json b/packages/spec/liveness/metadata_endpoints.json index 560963358f..b885efa437 100644 --- a/packages/spec/liveness/metadata_endpoints.json +++ b/packages/spec/liveness/metadata_endpoints.json @@ -1,6 +1,6 @@ { "type": "metadata_endpoints", - "_note": "MetadataEndpointsConfigSchema — packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema, the `metadata` sub-object of RestServerConfig. It is not a metadata type, not a request body and not a manifest: it is part of the REST server's CONSTRUCTION ARGUMENT, so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` take; check-liveness.mts carries the rationale, including why the four sub-objects are rooted separately instead of the whole RestServerConfigSchema (the walk drills one level, and rooting on the whole config would leave `metadata.endpoints.schema` and `batch.operations.upsertMany` with no row of their own). Seeded 2026-09-02 from the census filed with #14369, which is the second half of #11984's measurement: that PR made RestServer.normalizeConfig PARSE and CONSUME this sub-object instead of casting it. That settles accept/reject — an out-of-enum or out-of-range value is now refused at construction instead of sitting in the normalized config as if it were declared — and that is ALL it settles. Executing a declared contract does not give a key a consumer, which is exactly the distinction this file records. Mixed: `prefix`, `enableCache`, `maskObjectFields` and three of the four `endpoints.*` switches are read; `cacheTtl` and `endpoints.schema` are not. This file RECORDS status; it decides nothing. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor — the enforce route is a feature per key, and for a key that is published in an `@example` or in the generated reference docs the remove route is a capability retirement, not a tidy-up. Census method and scope, re-run at 2514d49f3 (2026-09-02): read sites in packages/rest/src non-test sources, excluding NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read); comments excluded; plus a repo-wide grep outside packages/spec and rest-server.ts, which finds only changesets, the generated reference docs and the #11984 refusal tests. objectui @d4c6a86 is clean (0 hits for every key here). The closed cloud runtime was not reachable from the measuring container, so the declared scope stays in-repo rather than claiming a sweep that was not run. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS — `stack.flows`, `stack.views`, … — and a RestServerConfig is not part of a stack at all: it is the argument a host passes when it constructs the server. Marking an entry `authorWarn` here would produce a warning nothing can emit, which is the same silent no-op this ledger exists to catch, so the dead entries below carry their correction in `note` and the construction-time parse (#11984) is what actually reaches the author — for accept/reject, which is a different question from liveness.", + "_note": "MetadataEndpointsConfigSchema — packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema, the `metadata` sub-object of RestServerConfig. It is not a metadata type, not a request body and not a manifest: it is part of the REST server's CONSTRUCTION ARGUMENT, so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` take; check-liveness.mts carries the rationale, including why the four sub-objects are rooted separately instead of the whole RestServerConfigSchema (the walk drills one level, and rooting on the whole config would leave `metadata.endpoints.schema` and `batch.operations.upsertMany` with no row of their own). Seeded 2026-09-02 from the census filed with #14369, which is the second half of #11984's measurement: that PR made RestServer.normalizeConfig PARSE and CONSUME this sub-object instead of casting it. That settles accept/reject — an out-of-enum or out-of-range value is now refused at construction instead of sitting in the normalized config as if it were declared — and that is ALL it settles. Executing a declared contract does not give a key a consumer, which is exactly the distinction this file records. Mixed: `prefix`, `enableCache`, `maskObjectFields` and four of the five `endpoints.*` switches are read; `cacheTtl` and `endpoints.schema` are not. (`endpoints.maintenance` was added 2026-09-06 by #15542 and is read from its first commit \u2014 see its row.) This file RECORDS status; it decides nothing. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor — the enforce route is a feature per key, and for a key that is published in an `@example` or in the generated reference docs the remove route is a capability retirement, not a tidy-up. Census method and scope, re-run at 2514d49f3 (2026-09-02): read sites in packages/rest/src non-test sources, excluding NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read); comments excluded; plus a repo-wide grep outside packages/spec and rest-server.ts, which finds only changesets, the generated reference docs and the #11984 refusal tests. objectui @d4c6a86 is clean (0 hits for every key here). The closed cloud runtime was not reachable from the measuring container, so the declared scope stays in-repo rather than claiming a sweep that was not run. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS — `stack.flows`, `stack.views`, … — and a RestServerConfig is not part of a stack at all: it is the argument a host passes when it constructs the server. Marking an entry `authorWarn` here would produce a warning nothing can emit, which is the same silent no-op this ledger exists to catch, so the dead entries below carry their correction in `note` and the construction-time parse (#11984) is what actually reaches the author — for accept/reject, which is a different question from liveness.", "props": { "prefix": { "status": "live", @@ -46,17 +46,25 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.items !== false)` gates four separate list-of-type route mounts)", + "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.items !== false)` gates the GET /meta/:type per-type list mount)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", - "note": "Same `!== false` shape as `types`." + "note": "Same `!== false` shape as `types`. RADIUS NARROWED 2026-09-06 (#15542): it used to gate four mounts \u2014 the per-type list plus the whole-store family (`/diagnostics`, `/_drafts` and the `POST /_migrate-stored` write door) \u2014 while its describe() named the list alone, so an operator who closed a listing read silently disarmed a migration door. The whole-store family now answers to its own key, `maintenance`, and this key gates exactly the one mount it names. Liveness is unchanged by that move (it was live before and is live now); what changed is the radius, which is the axis this ledger structurally cannot see \u2014 the mount table pin (packages/rest/src/rest-config-mount-table.pin.test.ts, the #15544 shape) is the instrument that does, and it asserts route ABSENCE per switch." }, "item": { "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.item !== false)` gates the GET /meta/:type/:name route mount)", + "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.item !== false)` gates the per-item read block, and `registerPerItemRoute` \u2014 the same switch, spelled as a call \u2014 gates the per-item PUT/DELETE and the history family)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", - "note": "Same `!== false` shape as `types`." + "note": "Same `!== false` shape as `types`. RADIUS WIDENED 2026-09-06 (#15854, ruled with #15542): it used to gate four READS (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and leave the per-item WRITES (`PUT` / `DELETE /:type/:name`) and the history family (`history`, `audit`, `diff`, `published`, `publish`, `rollback`) answering to `api.enableMetadata` alone \u2014 the converse of the `items` mismatch: an operator who closed the per-item surface left its writes mounted. It now gates the whole per-item face, twelve mounts, which is what the switch name has always promised. `GET /meta/object/:name/state/:field` is deliberately NOT part of that face (object FSM read, addressed by object name) and stays under `api.enableMetadata` alone." + }, + "maintenance": { + "status": "live", + "verifiedAt": "2026-09-06", + "evidenceScope": "in-repo", + "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (three `if (metadata.endpoints.maintenance !== false)` gates \u2014 `GET /meta/diagnostics`, `GET /meta/_drafts` and `POST /meta/_migrate-stored`)", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", + "note": "ADDED 2026-09-06 (#15542) as the whole-store family's own switch \u2014 the three mounts used to ride `endpoints.items`, whose declared meaning is the per-type list. Live from its first commit: it is a re-partition of an existing gated surface, not a newly declared key waiting for a consumer, and the mount table pin asserts its three routes leave when it is false. Same `!== false` shape as its siblings, default true, so an embedder who never authors it keeps every route it gates." }, "schema": { "status": "dead", From 4c4805034f59a8ba3729f84aa9fe4dbb928fd3f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:18:24 +0000 Subject: [PATCH 3/5] chore(spec): regenerate reference docs and liveness counts Co-Authored-By: Claude Opus 5 --- content/docs/references/api/rest-server.mdx | 13 +++++++------ packages/spec/liveness/state-counts.md | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 6a94b1a51d..6ad446c2d3 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -175,16 +175,17 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) | | **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. | | **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields | -| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints | +| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints | ### Nested Shape: `MetadataEndpointsConfig.endpoints` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `boolean` | optional (default: `true`) | GET /meta - List all metadata types | -| **items** | `boolean` | optional (default: `true`) | GET /meta/:type - List items of type | -| **item** | `boolean` | optional (default: `true`) | GET /meta/:type/:name - Get specific item | -| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` are the switches that gate real mounts. | +| **types** | `boolean` | optional (default: `true`) | Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths) | +| **items** | `boolean` | optional (default: `true`) | Mount the per-type item list — `GET /meta/:type`, and nothing else | +| **item** | `boolean` | optional (default: `true`) | Mount the whole per-item face — `GET`, `PUT` and `DELETE /meta/:type/:name`, its `/references` and `/layers` reads, the history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and `GET /meta/book/:name/tree` | +| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door | +| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` / `maintenance` are the switches that gate real mounts. | --- @@ -284,7 +285,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) | | **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. | | **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields | -| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints | +| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints | ### Nested Shape: `RestServerConfig.batch` diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 41cec46c46..3095afc96c 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -59,8 +59,8 @@ for both corollaries. | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | | `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | -| `metadata_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | +| `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **94** | **12** | **957** | +| **total** | **846** | **5** | **1** | **94** | **12** | **958** | From 484601894bd5b090426b435a14a0010707fc7ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:35:08 +0000 Subject: [PATCH 4/5] feat(spec): ADR-0087 D3 entry for the endpoints radius move; re-anchor system-context Co-Authored-By: Claude Opus 5 --- ...endpoints-switch-radius-maintenance-key.md | 19 ++++-- content/docs/permissions/system-context.mdx | 8 +-- ...a-endpoints-switch-radius-repartitioned.ts | 58 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 44 ++++++++++++++ 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts diff --git a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md index c38194c13a..2a3cff3136 100644 --- a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md +++ b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md @@ -62,15 +62,22 @@ is programmatic embedders only. That is precisely why this lands now — once a starts authoring the config, the same change becomes a behaviour change on live operators. -**ADR-0087 disposition: no D2 conversion entry and no D3 semantic migration.** No +**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No authored key changes shape or spelling — `items: false` still parses to `items: false`, `maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema` stays the #14691 tombstone it already was). There is nothing for the conversion layer to -convert and nothing for `migrate meta` to replay: a `RestServerConfig` is plugin TS -configuration, never a stack collection member and never a `sys_metadata` row (the -`RestServerConfig.openApi31` precedent, #4579), so no rehydration seam sees it. What -changes is a mounted route table at construction time, which is what the **BREAKING** -paragraph above is for and what the mount-table pin enforces. +convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member +and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no +rehydration seam sees it. What changes is a mounted route table at construction time. + +Nor is it compiler-carried: every key is an optional boolean, so `{ items: false }` +still compiles and still parses and simply mounts a different table. The two channels +that would otherwise reach a consumer are both blind, which is precisely the residue +D3 exists for — the prescription is registered as +`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands +it to an upgrading embedder instead of leaving it as prose in a changelog. + + `@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where the gates live, so the route-table change is observable there and not only in the diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0243376e89..84320c9133 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1749`, `:1778`), and neither can an action body (`packages/runtime/src/domains/actions.ts:414`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1771` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1781` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5319`, `:6766`, `:7014`, `:7445`, `:7638` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5411`, `:6868`, `:7116`, `:7547`, `:7740` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1739`, `:1768`; `domains/actions.ts:414` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1749`, `:1778`; `domains/actions.ts:414` | --- diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts new file mode 100644 index 0000000000..0a6d412c47 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#15542 / #15854] The `metadata.endpoints.*` switches were re-partitioned so that +// each gates exactly the face its name states. Nothing is renamed, nothing is +// retired and no stored row changes shape — a `RestServerConfig` is plugin TS +// configuration, never a stack collection member or a `sys_metadata` row (the +// `openApi31` precedent, #4579), so there is no D2 conversion to graduate here. +// What an embedder is owed is a PRESCRIPTION, because the mounted route table their +// existing config produces has moved in both directions, and the compiler cannot +// tell them: every key is optional and boolean, so the old spelling still compiles +// and still parses. That is exactly the residue D2 cannot express, which is why this +// is a semantic entry rather than a conversion. +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'metadata-endpoints-switch-radius-repartitioned', + surface: 'restServer.metadata.endpoints.items / restServer.metadata.endpoints.item', + replacement: + 'An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. ' + + '`items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take ' + + 'with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` ' + + 'write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the ' + + 'WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the ' + + 'history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and ' + + '`GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close ' + + 'the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that ' + + 'authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the ' + + 'writes: the per-item face is one face, so leave `item` on and close the surface at ' + + '`api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. ' + + '`types` is unchanged and `api.enableMetadata` remains the master switch above all four.', + reason: + 'Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise ' + + 'reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an ' + + 'optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a ' + + 'different route table. A D2 conversion would have to GUESS which of the four routes the author ' + + 'meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to ' + + '`{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author ' + + 'never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a ' + + 'judgment, so it is delegated rather than automated. The change itself is the ADR-0049 ' + + 'declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all ' + + 'three keys were genuinely live, and what had drifted was each one\'s RADIUS against its own ' + + '`describe()` — `items` gated a migration write door while naming a listing read (#15542), and ' + + '`item` gated four reads while its own `PUT` / `DELETE` and the history family answered to ' + + '`api.enableMetadata` alone (#15854). Ruled together by the maintainer as one principle. Measured ' + + 'population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig` ' + + '(#15543), so only programmatic embedders can have authored these keys at all.', + acceptanceCriteria: + 'For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed ' + + 'by `getRoutes()` yields the route table the consumer intends — specifically: with ' + + '`endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and ' + + '`POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; ' + + 'and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, ' + + '`DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored ' + + 'neither key is unaffected and needs no change: all four switches default `true` and the default ' + + 'route table is byte-identical to before. The reference measurement is ' + + '`packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch\'s radius as a ' + + 'set difference against the all-true baseline in both directions.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9e3c0dfa05..d1bf7b334b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7997,6 +7997,50 @@ const step18: MigrationStep = { + 'behaviour — the ADR-0005 org-overlay read/write path (`getMetaItemLayered`, the REST ' + 'meta write doors) stays exactly as it was, before and after.', }, + { + id: 'metadata-endpoints-switch-radius-repartitioned', + surface: 'restServer.metadata.endpoints.items / restServer.metadata.endpoints.item', + replacement: + 'An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. ' + + '`items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take ' + + 'with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` ' + + 'write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the ' + + 'WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the ' + + 'history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and ' + + '`GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close ' + + 'the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that ' + + 'authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the ' + + 'writes: the per-item face is one face, so leave `item` on and close the surface at ' + + '`api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. ' + + '`types` is unchanged and `api.enableMetadata` remains the master switch above all four.', + reason: + 'Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise ' + + 'reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an ' + + 'optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a ' + + 'different route table. A D2 conversion would have to GUESS which of the four routes the author ' + + 'meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to ' + + '`{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author ' + + 'never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a ' + + 'judgment, so it is delegated rather than automated. The change itself is the ADR-0049 ' + + 'declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all ' + + 'three keys were genuinely live, and what had drifted was each one\'s RADIUS against its own ' + + '`describe()` — `items` gated a migration write door while naming a listing read (#15542), and ' + + '`item` gated four reads while its own `PUT` / `DELETE` and the history family answered to ' + + '`api.enableMetadata` alone (#15854). Ruled together by the maintainer as one principle. Measured ' + + 'population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig` ' + + '(#15543), so only programmatic embedders can have authored these keys at all.', + acceptanceCriteria: + 'For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed ' + + 'by `getRoutes()` yields the route table the consumer intends — specifically: with ' + + '`endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and ' + + '`POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; ' + + 'and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, ' + + '`DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored ' + + 'neither key is unaffected and needs no change: all four switches default `true` and the default ' + + 'route table is byte-identical to before. The reference measurement is ' + + '`packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch\'s radius as a ' + + 'set difference against the all-true baseline in both directions.', + }, { id: 'metadata-item-name-grammar-enforced', surface: 'metadata item names (the `name` half of the `type`/`name` addressing pair — ' From 57b8e451ac63dd535adb49d034cc0b7e3443f1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:40:47 +0000 Subject: [PATCH 5/5] fix(qa): teach the authz blind-spot population rule the registerPerItemRoute spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-item family's later members register through a switch-carrying local helper instead of a direct `this.routeManager.register(` call. The census rule knew only the direct spelling, so it read population 73 / reachable 12 against a recorded 80 / 19 and the Dogfood Regression Gate went red. Re-recording 73/12 was the wrong repair: those 8 routes are still mounted and still registered inside `registerMetadataEndpoints`, so the lower number would have ratified a false population and encoded a 7-route blind spot in the census named for finding them. The rule now counts both spellings, excluding the helper's own forwarding call so it is not double-counted: 72 direct + 8 helper-routed = 80, and 11 + 8 = 19 reachable. Reachability was checked before the count was widened. `registerPerItemRoute` reads `this.routeManager` at call time and every call site is inside `registerMetadataEndpointsInner`, which runs under the anonymous-deny `guardedRouteManager` swap — so the helper hides nothing from the probe. That is now measured rather than argued: rest-meta-auth.test.ts drives an anonymous `GET /meta/:type/:name/history` to 401 with the history read never reached. Both halves of the new rule carry exact positive controls so neither can go silently to zero. Also completes the changeset's BREAKING paragraph: `MetadataEndpointsConfigParsed` gained a required `maintenance: boolean` on the parsed (output) side — an ADR-0087 D8 compiler-carried narrowing that was implemented but not written down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- ...endpoints-switch-radius-maintenance-key.md | 24 +++- .../test/authz-probe-blind-spot.census.ts | 105 +++++++++++++++++- packages/rest/src/rest-meta-auth.test.ts | 35 ++++++ 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md index 2a3cff3136..cbc798a903 100644 --- a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md +++ b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md @@ -54,6 +54,18 @@ the mounted route table moves for two of the four keys, in opposite directions: mounted **loses those eight**. There is no key that restores them — the per-item face is one face by this ruling — so an embedder that wants the writes keeps `item` on and closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`. +- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a + REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`, + so it is optional on the way *in* and always present on the way *out* — and + `MetadataEndpointsConfigParsed` is `z.infer`, the + OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a + helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal — + stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS + compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it + lands at build time, and no runtime behaviour depends on the author noticing a + changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep + the whole-store family closed. In-repo consumers of the type: none — the narrowing was + measured against a probe compiled from the rebuilt declaration, not assumed. Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no shipped boot path** today (`os serve` fixes the config and the dev plugin passes none, @@ -70,10 +82,14 @@ convert: a `RestServerConfig` is plugin TS configuration, never a stack collecti and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no rehydration seam sees it. What changes is a mounted route table at construction time. -Nor is it compiler-carried: every key is an optional boolean, so `{ items: false }` -still compiles and still parses and simply mounts a different table. The two channels -that would otherwise reach a consumer are both blind, which is precisely the residue -D3 exists for — the prescription is registered as +Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a +D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still +compiles and still parses and simply mounts a different table: the author is told +nothing. (The parsed-type narrowing in the third BREAKING bullet above *is* +compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot +reach the embedder who authored `{ items: false }` and now silently gets three routes +back.) So for the change that actually moves the route table, both channels that would +otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as `metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands it to an upgrading embedder instead of leaving it as prose in a changelog. diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 9e920afbb3..d34e04f115 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -77,7 +77,12 @@ // // `rest-server.ts` is pinned below at its STATIC reading: 80 `routeManager` // call sites, 17 registrars, 19 sites inside the one mintable registrar, 61 -// outside. A RUNTIME census — construct `RestServer` against a recording +// outside. ⭐ [#15542] Those 80 are counted across TWO spellings since the +// per-item family gained a switch-carrying helper — 72 direct +// `this.routeManager.register(` sites plus 8 `registerPerItemRoute(` calls, the +// helper's own forwarding call excluded so it is not counted twice. The total +// did not move; the rule had to learn the second spelling to keep reading it. +// A RUNTIME census — construct `RestServer` against a recording // `RouteManager` and a protocol implementing every optional capability, then // call each registrar — reads 85 / 17 / 19 / 66 instead. Both are correct and // the delta is fully explained: `registerApprovalsEndpoints` builds 12 routes @@ -312,7 +317,47 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ population: 80, reachable: 19, blindSpot: 61, - populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints', + populationRule: + 'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' + + '`registerPerItemRoute` (the shared forwarder, not a route), PLUS `registerPerItemRoute(` call sites; ' + + 'reachable = those inside registerMetadataEndpoints', + // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and + // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. + // + // WHAT MOVED IN THE SOURCE. The per-item family's later members (`PUT`, + // `DELETE`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, + // `/rollback`) stopped being direct `this.routeManager.register(` call + // sites and became `registerPerItemRoute(` calls — a local helper carrying + // the `endpoints.item` switch. Net -7 on the old one-spelling reading: 8 + // sites left that spelling and the helper's own forwarding call added 1 + // back. So the old rule read population 73 / reachable 12. + // + // ⛔ 73 / 12 WAS NOT RE-RECORDED, and the reason is this file's whole + // purpose. Those 8 routes are still mounted and still registered exactly + // where they were; only the spelling of the call changed. Writing 73 down + // would have ratified a population 7 short of the real one and encoded a + // 7-route hole in the very blind-spot count this census exists to keep + // honest — the failure it is named for, committed by its own record. + // + // ⚠️ REACHABILITY WAS CHECKED BEFORE THE COUNT WAS WIDENED, because if the + // helper HID those routes from the probe the repair would belong in + // `rest-server.ts` and not here. It does not. `registerPerItemRoute` reads + // `this.routeManager` at CALL time and every one of its 8 call sites is + // lexically inside `registerMetadataEndpointsInner`, which + // `registerMetadataEndpoints` runs with `this.routeManager` swapped to the + // anonymous-deny `guardedRouteManager` and restored in a `finally`. So a + // helper-routed registration goes through the identical wrapping the 11 + // remaining direct sites in that registrar do, and the umbrella key + // `meta:rest-server.ts:registerMetadataEndpoints` covers it unchanged. + // Pinned at runtime rather than argued from source: + // `packages/rest/src/rest-meta-auth.test.ts` drives an anonymous + // `GET {meta}/:type/:name/history` — a helper-routed route — to 401. + // + // The decomposition, so the two halves stay legible: 72 direct route + // registrations + 8 helper-routed = 80 population; 11 + 8 = 19 reachable. + // `this.routeManager.register(` reads 73 because the helper's forwarder is + // one of them, and it is sliced out before counting. + // // [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control // exists to prove this census is still reading the file it thinks it is, and // a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214 @@ -329,7 +374,21 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // 80, `reachable` 19, `private register*Endpoints(` 17 and // `this.routeManager.register(` 80 are all unchanged — #13214 added no route // and no registrar. `blindSpot` therefore stays 61 as well. - controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 }, + // ⚠️ That last figure is the reading AS OF #13214 and is left as written: + // the control is 73 today for the spelling reason recorded above, and the + // population it feeds is still 80. Do not "correct" the paragraph — it is a + // dated measurement, not a live claim. + controls: { + 'private register*Endpoints(': 17, + 'this.routeManager.register(': 73, + // Both halves of the new rule carry their own control, so neither can go + // silently to zero: a helper deleted and its routes inlined back would + // still read population 80, and only these two controls would notice the + // shape moved and force this provenance to be re-read. + 'registerPerItemRoute(': 8, + 'const registerPerItemRoute =': 1, + enforceAuth: 64, + }, note: 'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' + 'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' + @@ -560,21 +619,57 @@ export function deriveProbeFileCensus(): { } // ── rest-server.ts ────────────────────────────────────────────────────── + // + // [#15542 / #15854] TWO SPELLINGS, ONE POPULATION. A route registration in + // this file is EITHER a direct `this.routeManager.register(` call site OR a + // `registerPerItemRoute(` call — the local helper the per-item family's later + // members go through, which carries the `endpoints.item` switch and forwards + // to `this.routeManager.register(entry)`. Both are registrations; counting + // only the first spelling reads 7 short. + // + // ⛔ The helper's OWN forwarding call is NOT a registration site — it is the + // one shared mechanism 8 sites go through — so its body is sliced out before + // counting. Counting it would double-count every helper-routed route. { const src = read('packages/rest/src/rest-server.ts'); const registrarRe = /^\s*private\s+register[A-Za-z]*Endpoints\s*\(/gm; const mountRe = /this\.routeManager\.register\(/g; + // Matches the CALL sites only. The declaration reads + // `const registerPerItemRoute = (` and the docblock mentions read + // `{@link registerPerItemRoute}` — in neither is the name followed by `(`. + const helperCallRe = /registerPerItemRoute\(/g; + const helperDeclRe = /const\s+registerPerItemRoute\s*=/; + + /** + * Registration sites in one haystack: direct call sites, LESS the helper's + * own forwarding call, PLUS the helper's call sites. + * + * ⛔ Fail-loud, like the ledger marker slice above: a helper declaration + * that moves out of this shape slices to '' and nothing is subtracted, so + * the reading comes out ONE HIGH (81 / 20) and this census goes RED. It + * never silently shrinks — a quietly narrower rule is the failure mode the + * whole file is built against. + */ + const sites = (hay: string): number => { + const at = hay.search(helperDeclRe); + const stop = at < 0 ? -1 : hay.indexOf('\n };', at); + const forwarder = at < 0 || stop < 0 ? '' : hay.slice(at, stop); + return occurrences(hay, mountRe) - occurrences(forwarder, mountRe) + occurrences(hay, helperCallRe); + }; + // Slice the mintable registrar's body: from its declaration to the next one. const decls = [...src.matchAll(registrarRe)].map((m) => ({ at: m.index ?? 0, text: m[0] })); const metaIdx = decls.findIndex((d) => d.text.includes('registerMetadataEndpoints')); const start = decls[metaIdx]?.at ?? 0; const end = decls[metaIdx + 1]?.at ?? src.length; files.set('packages/rest/src/rest-server.ts', { - population: occurrences(src, mountRe), - reachable: occurrences(src.slice(start, end), /this\.routeManager\.register\(/g), + population: sites(src), + reachable: sites(src.slice(start, end)), controls: { 'private register*Endpoints(': occurrences(src, /private\s+register[A-Za-z]*Endpoints\s*\(/g), 'this.routeManager.register(': occurrences(src, /this\.routeManager\.register\(/g), + 'registerPerItemRoute(': occurrences(src, /registerPerItemRoute\(/g), + 'const registerPerItemRoute =': occurrences(src, /const\s+registerPerItemRoute\s*=/g), enforceAuth: occurrences(src, /enforceAuth/g), }, }); diff --git a/packages/rest/src/rest-meta-auth.test.ts b/packages/rest/src/rest-meta-auth.test.ts index 26f6cf56c8..3623f123ef 100644 --- a/packages/rest/src/rest-meta-auth.test.ts +++ b/packages/rest/src/rest-meta-auth.test.ts @@ -67,6 +67,41 @@ describe('RestServer metadata routes — anonymous-deny gate (#3963)', () => { expect(protocol.getMetaItems).toHaveBeenCalled(); }); + // [#15542 / #15854] The per-item family's later members register through + // `registerPerItemRoute` rather than by a direct `this.routeManager.register(` + // call. That helper reads `this.routeManager` at CALL time, so it registers + // through the same `guardedRouteManager` swap the direct sites do — but that + // is a source argument, and the gate is worth a MEASUREMENT. Without this + // case the whole helper-routed half of the surface (`PUT`, `DELETE`, + // `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) had + // no test proving it is still anonymously denied, and a future refactor that + // captured `this.routeManager` at definition time would register all eight + // past the gate with every existing test still green. + // + // It is also what licenses `authz-probe-blind-spot.census.ts` counting those + // 8 as REACHABLE by `meta:rest-server.ts:registerMetadataEndpoints`. + it('401s an anonymous helper-routed per-item route — the gate travels with `registerPerItemRoute` (#15542)', async () => { + const protocol: any = { historyMetaItem: vi.fn().mockResolvedValue({ events: [] }) }; + const rest = new RestServer(makeServer() as any, protocol, {} as any); + rest.registerRoutes(); + const route = rest + .getRoutes() + .find((r) => r.method === 'GET' && /\/meta\/:type\/:name\/history$/.test(r.path)); + if (!route) throw new Error('GET /meta/:type/:name/history route not registered'); + + const { res, state } = makeRes(); + await (route.handler as (req: any, res: any) => Promise)( + { method: 'GET', params: { type: 'object', name: 'sys_metadata' }, query: {}, headers: {} }, + res, + ); + + expect(state.status).toBe(401); + expect(state.body?.error).toBe('UNAUTHENTICATED'); + // Short-circuited BEFORE the history read — the per-org event log did + // not leak, which is the same property the direct sites are pinned for. + expect(protocol.historyMetaItem).not.toHaveBeenCalled(); + }); + it('still 401s an anonymous /meta/object — the opt-out is retired (#3963)', async () => { // `api.requireAuth: false` used to serve object schemas anonymously. The // opt-out is gone: object metadata is never public. (Only the book/doc