Skip to content

Commit 26bc91f

Browse files
committed
Merge commit '6c546ab9d0b4842bf363caac2a9ff852277680b5' into claude/issue-16161-empty-generator-eval-refusal
2 parents d03fdbf + 6c546ab commit 26bc91f

54 files changed

Lines changed: 2087 additions & 343 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
`POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly.
7+
8+
**The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph:
9+
10+
- before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"`
11+
- after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote
12+
13+
The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives.
14+
15+
Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`.
16+
17+
The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim.

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/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +296 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +297 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -224,6 +224,7 @@ const result = ApiErrorSchema.parse(data);
224224
* `METADATA_NOT_FOUND`
225225
* `METADATA_SCHEMA_INVALID`
226226
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
227+
* `NAMESPACE_CONFLICT`
227228
* `NAMESPACE_PREFIX`
228229
* `NEEDS_PASSWORD`
229230
* `NODE_FAILURE`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ const result = ErrorCode.parse(data);
340340
* `METADATA_NOT_FOUND`
341341
* `METADATA_SCHEMA_INVALID`
342342
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
343+
* `NAMESPACE_CONFLICT`
343344
* `NAMESPACE_PREFIX`
344345
* `NEEDS_PASSWORD`
345346
* `NODE_FAILURE`

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)