Skip to content

Commit 784cb92

Browse files
huangyiireneclaude
andauthored
feat(spec): every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own key maintenance (#16243)
* wip(rest): maintenance switch + per-item radius Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: ledger, pins, checklist, changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): regenerate reference docs and liveness counts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(spec): ADR-0087 D3 entry for the endpoints radius move; re-anchor system-context Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(qa): teach the authz blind-spot population rule the registerPerItemRoute spelling 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4a1a3b0 commit 784cb92

16 files changed

Lines changed: 661 additions & 85 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
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)
7+
8+
`RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming
9+
exactly one route, and each gated a different set. The mismatch ran in **both**
10+
directions at once:
11+
12+
- **`items`** — declared "GET /meta/:type - List items of type" — also gated the
13+
whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the
14+
draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**.
15+
An operator who switched off a listing read they considered chatty silently unmounted
16+
a migration door.
17+
- **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four
18+
*reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the
19+
per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family
20+
(`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to
21+
`api.enableMetadata` alone. An operator who closed the per-item surface left its
22+
writes mounted.
23+
24+
Neither is a liveness defect — all three keys were genuinely read — which is why no
25+
ADR-0049 census could ever flag them: what drifted was each key's **radius** against its
26+
own documentation.
27+
28+
**One principle now holds across the block: a switch gates exactly the face its name
29+
states, reads and writes alike.**
30+
31+
| key | mounts it gates (default prefix `/meta`) |
32+
|---|---|
33+
| `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) |
34+
| `items` | `GET /meta/:type` — and nothing else |
35+
| `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` |
36+
| `maintenance` | **new**`GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` |
37+
38+
All four `describe()` strings are rewritten to enumerate what they gate, so the
39+
generated reference page is the radius rather than a sample of it.
40+
`api.enableMetadata` remains the master switch above all four, and
41+
`GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name
42+
rather than by `:type/:name` — deliberately stays under that master switch alone.
43+
44+
**BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`,
45+
the mounted route table moves for two of the four keys, in opposite directions:
46+
47+
- **`items: false` now removes one route instead of four.** An embedder relying on it to
48+
close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all
49+
three** unless it also sets `maintenance: false`. That is a write door coming back, so
50+
it is the half to read twice. One line restores the old table:
51+
`endpoints: { items: false, maintenance: false }`.
52+
- **`item: false` now removes twelve routes instead of four.** An embedder relying on it
53+
to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family
54+
mounted **loses those eight**. There is no key that restores them — the per-item face is
55+
one face by this ruling — so an embedder that wants the writes keeps `item` on and
56+
closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`.
57+
- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a
58+
REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`,
59+
so it is optional on the way *in* and always present on the way *out* — and
60+
`MetadataEndpointsConfigParsed` is `z.infer<typeof MetadataEndpointsConfigSchema>`, the
61+
OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a
62+
helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal —
63+
stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS
64+
compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it
65+
lands at build time, and no runtime behaviour depends on the author noticing a
66+
changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep
67+
the whole-store family closed. In-repo consumers of the type: none — the narrowing was
68+
measured against a probe compiled from the rebuilt declaration, not assumed.
69+
70+
Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no
71+
shipped boot path** today (`os serve` fixes the config and the dev plugin passes none,
72+
#15543), so the measured population of affected authors is **zero** and the blast radius
73+
is programmatic embedders only. That is precisely why this lands now — once a boot path
74+
starts authoring the config, the same change becomes a behaviour change on live
75+
operators.
76+
77+
**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No
78+
authored key changes shape or spelling — `items: false` still parses to `items: false`,
79+
`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema`
80+
stays the #14691 tombstone it already was). There is nothing for the conversion layer to
81+
convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member
82+
and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no
83+
rehydration seam sees it. What changes is a mounted route table at construction time.
84+
85+
Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a
86+
D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still
87+
compiles and still parses and simply mounts a different table: the author is told
88+
nothing. (The parsed-type narrowing in the third BREAKING bullet above *is*
89+
compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot
90+
reach the embedder who authored `{ items: false }` and now silently gets three routes
91+
back.) So for the change that actually moves the route table, both channels that would
92+
otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as
93+
`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands
94+
it to an upgrading embedder instead of leaving it as prose in a changelog.
95+
96+
<!-- adr-0087: registered metadata-endpoints-switch-radius-repartitioned -->
97+
98+
`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where
99+
the gates live, so the route-table change is observable there and not only in the
100+
declaration.
101+
102+
Every key's radius is pinned route by route, in both directions, in
103+
`packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts
104+
each route is **absent from the mounted table** when its switch is off rather than what
105+
the switch normalizes to. A gate that grows or loses a route reddens there.

content/docs/permissions/system-context.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1749`, `:1778`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 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` |
104104
| 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` |
105105
| 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` |
106-
| 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` |
106+
| 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` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 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` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 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` |
161+
| 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` |
162162
| 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` |
163163
| 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` |
164164
| 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.
199199
| "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) |
200200
| "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` |
201201
| "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` |
202-
| "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` |
202+
| "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` |
203203

204204
---
205205

content/docs/references/api/rest-server.mdx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,16 +175,17 @@ const result = BatchEndpointsConfigSchema.parse(data);
175175
| **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) |
176176
| **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. |
177177
| **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields |
178-
| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints |
178+
| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints |
179179

180180
### Nested Shape: `MetadataEndpointsConfig.endpoints`
181181

182182
| Property | Type | Required | Description |
183183
| :--- | :--- | :--- | :--- |
184-
| **types** | `boolean` | optional (default: `true`) | GET /meta - List all metadata types |
185-
| **items** | `boolean` | optional (default: `true`) | GET /meta/:type - List items of type |
186-
| **item** | `boolean` | optional (default: `true`) | GET /meta/:type/:name - Get specific item |
187-
| **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. |
184+
| **types** | `boolean` | optional (default: `true`) | Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths) |
185+
| **items** | `boolean` | optional (default: `true`) | Mount the per-type item list — `GET /meta/:type`, and nothing else |
186+
| **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` |
187+
| **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 |
188+
| **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. |
188189

189190

190191
---
@@ -284,7 +285,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
284285
| **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) |
285286
| **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. |
286287
| **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields |
287-
| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints |
288+
| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints |
288289

289290
### Nested Shape: `RestServerConfig.batch`
290291

0 commit comments

Comments
 (0)