Skip to content

Commit 4846018

Browse files
committed
feat(spec): ADR-0087 D3 entry for the endpoints radius move; re-anchor system-context
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4c48050 commit 4846018

4 files changed

Lines changed: 119 additions & 10 deletions

File tree

.changeset/metadata-endpoints-switch-radius-maintenance-key.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,22 @@ is programmatic embedders only. That is precisely why this lands now — once a
6262
starts authoring the config, the same change becomes a behaviour change on live
6363
operators.
6464

65-
**ADR-0087 disposition: no D2 conversion entry and no D3 semantic migration.** No
65+
**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No
6666
authored key changes shape or spelling — `items: false` still parses to `items: false`,
6767
`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema`
6868
stays the #14691 tombstone it already was). There is nothing for the conversion layer to
69-
convert and nothing for `migrate meta` to replay: a `RestServerConfig` is plugin TS
70-
configuration, never a stack collection member and never a `sys_metadata` row (the
71-
`RestServerConfig.openApi31` precedent, #4579), so no rehydration seam sees it. What
72-
changes is a mounted route table at construction time, which is what the **BREAKING**
73-
paragraph above is for and what the mount-table pin enforces.
69+
convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member
70+
and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no
71+
rehydration seam sees it. What changes is a mounted route table at construction time.
72+
73+
Nor is it compiler-carried: every key is an optional boolean, so `{ items: false }`
74+
still compiles and still parses and simply mounts a different table. The two channels
75+
that would otherwise reach a consumer are both blind, which is precisely the residue
76+
D3 exists for — the prescription is registered as
77+
`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands
78+
it to an upgrading embedder instead of leaving it as prose in a changelog.
79+
80+
<!-- adr-0087: registered metadata-endpoints-switch-radius-repartitioned -->
7481

7582
`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where
7683
the gates live, so the route-table change is observable there and not only in the

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#15542 / #15854] The `metadata.endpoints.*` switches were re-partitioned so that
4+
// each gates exactly the face its name states. Nothing is renamed, nothing is
5+
// retired and no stored row changes shape — a `RestServerConfig` is plugin TS
6+
// configuration, never a stack collection member or a `sys_metadata` row (the
7+
// `openApi31` precedent, #4579), so there is no D2 conversion to graduate here.
8+
// What an embedder is owed is a PRESCRIPTION, because the mounted route table their
9+
// existing config produces has moved in both directions, and the compiler cannot
10+
// tell them: every key is optional and boolean, so the old spelling still compiles
11+
// and still parses. That is exactly the residue D2 cannot express, which is why this
12+
// is a semantic entry rather than a conversion.
13+
import type { SemanticMigration } from '../../types.js';
14+
15+
export const entry: SemanticMigration = {
16+
id: 'metadata-endpoints-switch-radius-repartitioned',
17+
surface: 'restServer.metadata.endpoints.items / restServer.metadata.endpoints.item',
18+
replacement:
19+
'An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. '
20+
+ '`items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take '
21+
+ 'with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` '
22+
+ 'write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the '
23+
+ 'WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the '
24+
+ 'history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and '
25+
+ '`GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close '
26+
+ 'the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that '
27+
+ 'authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the '
28+
+ 'writes: the per-item face is one face, so leave `item` on and close the surface at '
29+
+ '`api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. '
30+
+ '`types` is unchanged and `api.enableMetadata` remains the master switch above all four.',
31+
reason:
32+
'Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise '
33+
+ 'reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an '
34+
+ 'optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a '
35+
+ 'different route table. A D2 conversion would have to GUESS which of the four routes the author '
36+
+ 'meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to '
37+
+ '`{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author '
38+
+ 'never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a '
39+
+ 'judgment, so it is delegated rather than automated. The change itself is the ADR-0049 '
40+
+ 'declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all '
41+
+ 'three keys were genuinely live, and what had drifted was each one\'s RADIUS against its own '
42+
+ '`describe()` — `items` gated a migration write door while naming a listing read (#15542), and '
43+
+ '`item` gated four reads while its own `PUT` / `DELETE` and the history family answered to '
44+
+ '`api.enableMetadata` alone (#15854). Ruled together by the maintainer as one principle. Measured '
45+
+ 'population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig` '
46+
+ '(#15543), so only programmatic embedders can have authored these keys at all.',
47+
acceptanceCriteria:
48+
'For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed '
49+
+ 'by `getRoutes()` yields the route table the consumer intends — specifically: with '
50+
+ '`endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and '
51+
+ '`POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; '
52+
+ 'and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, '
53+
+ '`DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored '
54+
+ 'neither key is unaffected and needs no change: all four switches default `true` and the default '
55+
+ 'route table is byte-identical to before. The reference measurement is '
56+
+ '`packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch\'s radius as a '
57+
+ 'set difference against the all-true baseline in both directions.',
58+
};

packages/spec/src/migrations/registry.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7997,6 +7997,50 @@ const step18: MigrationStep = {
79977997
+ 'behaviour — the ADR-0005 org-overlay read/write path (`getMetaItemLayered`, the REST '
79987998
+ 'meta write doors) stays exactly as it was, before and after.',
79997999
},
8000+
{
8001+
id: 'metadata-endpoints-switch-radius-repartitioned',
8002+
surface: 'restServer.metadata.endpoints.items / restServer.metadata.endpoints.item',
8003+
replacement:
8004+
'An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. '
8005+
+ '`items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take '
8006+
+ 'with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` '
8007+
+ 'write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the '
8008+
+ 'WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the '
8009+
+ 'history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and '
8010+
+ '`GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close '
8011+
+ 'the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that '
8012+
+ 'authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the '
8013+
+ 'writes: the per-item face is one face, so leave `item` on and close the surface at '
8014+
+ '`api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. '
8015+
+ '`types` is unchanged and `api.enableMetadata` remains the master switch above all four.',
8016+
reason:
8017+
'Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise '
8018+
+ 'reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an '
8019+
+ 'optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a '
8020+
+ 'different route table. A D2 conversion would have to GUESS which of the four routes the author '
8021+
+ 'meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to '
8022+
+ '`{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author '
8023+
+ 'never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a '
8024+
+ 'judgment, so it is delegated rather than automated. The change itself is the ADR-0049 '
8025+
+ 'declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all '
8026+
+ 'three keys were genuinely live, and what had drifted was each one\'s RADIUS against its own '
8027+
+ '`describe()` — `items` gated a migration write door while naming a listing read (#15542), and '
8028+
+ '`item` gated four reads while its own `PUT` / `DELETE` and the history family answered to '
8029+
+ '`api.enableMetadata` alone (#15854). Ruled together by the maintainer as one principle. Measured '
8030+
+ 'population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig` '
8031+
+ '(#15543), so only programmatic embedders can have authored these keys at all.',
8032+
acceptanceCriteria:
8033+
'For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed '
8034+
+ 'by `getRoutes()` yields the route table the consumer intends — specifically: with '
8035+
+ '`endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and '
8036+
+ '`POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; '
8037+
+ 'and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, '
8038+
+ '`DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored '
8039+
+ 'neither key is unaffected and needs no change: all four switches default `true` and the default '
8040+
+ 'route table is byte-identical to before. The reference measurement is '
8041+
+ '`packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch\'s radius as a '
8042+
+ 'set difference against the all-true baseline in both directions.',
8043+
},
80008044
{
80018045
id: 'metadata-item-name-grammar-enforced',
80028046
surface: 'metadata item names (the `name` half of the `type`/`name` addressing pair — '

0 commit comments

Comments
 (0)