|
| 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. |
0 commit comments