feat(spec): every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own key maintenance - #16243
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1f9711a77b62e13046d37f42600d82cb9cd76de3 && git checkout 1f9711a77b62e13046d37f42600d82cb9cd76de3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 618f70d74d934c09fa58f1eaa8493e95e4f847e9 484601894bd5b090426b435a14a0010707fc7ec3 && git checkout -B drift-repro 618f70d74d934c09fa58f1eaa8493e95e4f847e9 && git merge --no-ff 484601894bd5b090426b435a14a0010707fc7ec3
node scripts/docs-audit/affected-docs.mjs --json 618f70d74d934c09fa58f1eaa8493e95e4f847e9
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r system-context Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contract review (clause ②) — PASS on content · NOT LANDABLE (red CI, real, this PR's) — PR #16243 at head
|
Fixes #15542
Part of #15854
Implements the maintainer ruling recorded on #15542 (comment 5557095147, director seat, batch #53): an
endpoints.*switch gates exactly the face its name states, reads and writes alike. Both halves land here because they are one re-partition of one surface — #15854 is this card's converse mismatch, one switch over.⛔ Draft on purpose, and it must stay that way. Clause-② is
yes;claude-fable-5-1was exhausted platform-wide when this was dispatched, so construction ran atclaude-opus-5under the quota-exhaustion exemption. Review does not take that exemption. Do not flip ready and do not enqueue until an at-tierneeds:contract-reviewPASS exists.The defect, in both directions at once
RestServerConfig.metadata.endpointsdeclared three switches, eachdescribe()naming exactly one route, and each gated a different set:typesGET /metaGET /metaandGET /meta/types, one handler, two paths (deliberate)itemsGET /meta/:typeGET /meta/diagnostics,GET /meta/_draftsand thePOST /meta/_migrate-storedwrite dooritemGET /meta/:type/:namePUT/DELETE, nor the history family, which answered toapi.enableMetadataaloneSo an operator who switched off a listing read they considered chatty silently unmounted a migration write door; and 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 flag either: what drifted was each key's radius against its own documentation, the axis the ledger structurally cannot see.
The code's own routing comment already separated "whole-store operations (
/diagnostics,/_drafts,/_migrate-stored)" from "the per-type list (/:type)". The taxonomy was ahead of the switch surface. This makes the switches equal to it.What lands
/meta)typesGET /meta,GET /meta/types— unchangeditemsGET /meta/:type— and nothing elseitemGET/PUT/DELETE /meta/:type/:name,/references,/layers,/history,/audit,/diff,/published,/publish,/rollback,GET /meta/book/:name/tree— 12maintenancetrue—GET /meta/diagnostics,GET /meta/_drafts,POST /meta/_migrate-stored— 3packages/spec/src/api/rest-server.zod.ts— the new key, and all fourdescribe()strings rewritten to enumerate what they gate. The radius is the contract now, not a sample of it.packages/rest/src/rest-server.ts— the three whole-store gates readmaintenance; the per-item family's later members (PUT,DELETE,history,audit,publish,rollback,diff,published) go through a new localregisterPerItemRoute, which carries the sameendpoints.itemswitch.packages/rest/src/rest-config-mount-table.pin.test.ts— the [finding] The MOUNT half of everyRestServerConfigswitch is unpinned — the tests assert what a switch normalizes to, never that the route leaves the table #15544 mount-absent pins re-stated on the new radii, not deleted (ruling item 5). Each key keeps a row asserting the exact set of routes that leaves when it isfalse, in both directions, each with its presence twin. The§0exhaustiveness count moves 19 → 20 with the new switch.packages/spec/liveness/metadata_endpoints.json— amaintenancerow, and radius notes onitems/itemrecording that the move was ruled rather than drifted.packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts— the ADR-0087 D3 prescription (see below).docs/qa/platform-checklist/— see below; the acceptance clause is kept and re-pointed, never retired.Why
registerPerItemRouteis a call and not one moreifblockTwo reasons, neither cosmetic. (1) No single brace pair contains exactly the right set: the later members of the per-item face are spread over ~1200 lines with
GET /meta/object/:name/state/:field— deliberately not part of that 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 precisely how this switch came to gate four reads and none of its own writes. It readsthis.routeManagerat call time, deliberately:registerMetadataEndpointsswaps in the anonymous-deny wrapping registrar for the duration of the method, so a captured reference would register past that gate.A secondary benefit is diff hygiene on a busy cross-lane file: wrapping those eight registrations in
ifblocks would have re-indented ~1200 lines ofrest-server.tsfor no behavioural gain.One route deliberately left alone
GET /meta/object/:name/state/:field— the object FSM read, addressed by object name rather than by:type/:name— is in no per-family switch and still answers toapi.enableMetadataalone. The ruling's enumeration does not name it, so moving it under a switch would be a fresh decision, not a tidy-up. It is called out in the registrar docblock and in the pin table so the next reader does not read its absence as an oversight.BREAKING — priced and accepted (ruling item 6)
For a programmatic embedder that authors
RestServerConfig.metadata.endpoints, the mounted table moves for two keys, in opposite directions:items: falseremoves one route instead of four. An embedder relying on it to close the whole-store family regains all three routes, the write door among them, unless it also setsmaintenance: false. One line restores the old table:endpoints: { items: false, maintenance: false }.item: falseremoves twelve instead of four. An embedder relying on it to close only the per-item reads losesPUT,DELETEand the history family too. No key restores them — the per-item face is one face by this ruling.Measured blast radius today is zero:
RestServerConfigis reachable from no shipped boot path (#15543 —os servefixes the config and the dev plugin passes none), so the affected population is programmatic embedders only. That is exactly why it lands now rather than after a boot path starts authoring it.ADR-0087 disposition: a D3 semantic migration is REGISTERED; no D2 conversion. The changeset carries
adr-0087: registered metadata-endpoints-switch-radius-repartitionedandcheck-adr-0087-registrationaccepts it as new in this diff. There is nothing for the conversion layer to convert — no key is renamed, removed or retyped, and aRestServerConfigis plugin TS configuration, never a stack collection member and never asys_metadatarow (theRestServerConfig.openApi31precedent, #4579), so no rehydration seam sees it. But an embedder is owed a prescription, because both channels that would otherwise reach them are blind here: every key is an optional boolean, so{ items: false }still compiles and still parses and simply mounts a different table. A D2 conversion could not decide the question either — rewriting{ items: false }to{ items: false, maintenance: false }preserves the old mounts but presumes an intent the author never expressed, and leaving it alone re-mounts a write door. That judgment is delegated, which is exactly what D3 is for.The checklist item is kept satisfiable, not made vacuous (ruling item 7)
docs/qa/platform-checklist/areas/api-backend.json→api-backend.rest-metadata-config-contractis at revision 2. Its acceptance clause still requires a run to ENUMERATE each switch's real radius from a route-table diff rather than trust thedescribe(); what changed is the expected sets, plus a fourthmaintenancestep and the both-directions rule (a switch that grows a route is as much a FAIL as one that loses one). Left stale it would not have gone vacuous — it would have produced a false FAIL against the new radii, which is worse.FOLLOW-UPS.md§10b E1 records the ruling and points at revision 2; §10b E3's "nineteen gates pinned" is refreshed to twenty.Declared scope increments (for the PM)
Three paths outside the claim's declared file surface, all named here rather than folded in silently:
docs/qa/platform-checklist/**— required by ruling item 7, as above. Not optional: the alternative is a checklist item that fails against the tree it is meant to measure.packages/spec/liveness/metadata_endpoints.json— a new declared key needs its ADR-0049 ledger row, anditems/itemcarry radius notes that were measured statements about the old gates.packages/spec/src/migrations/entries/semantic/+ the generatedregistry.ts—check-adr-0087-registrationrefuses a**BREAKING**changeset with no disposition marker, andnot-required (no-migration-prescription)is unavailable to a body that (correctly) prescribes one. Registering was the honest answer, not a wider scope.Plus
content/docs/permissions/system-context.mdx, which is line-anchored intorest-server.tsand was re-anchored mechanically bycheck-system-context-census --fix(10 anchors, no prose change).One bounded in-place fix in the same docblock this card rewrites:
MetadataEndpointsConfigSchema's@exampleadvertisedendpoints: { types, objects, fields }— two keys the schema has never declared. It is the same declared-versus-real class the card is about, sitting three lines above the block being changed, and it now shows the four real keys.Verification
All figures measured on this branch at
484601894, the final commit.Tests —
bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/spec --filter @objectstack/rest run test",VERDICT command-exit 0:pnpm --filter … run typecheckfor both:VERDICT command-exit 0(check:test-typecheckreports the test layer at 0 files / 0 errors for@objectstack/rest).Reverse verification — the pin can actually fail. With the implementation committed, the
_migrate-storedgate was mutated back toendpoints.itemsand the pin re-run:Direction observed, stated rather than assumed: two rows reddened, not one —
metadata.endpoints.itemsgrew a route andmetadata.endpoints.maintenancelost one. That is the set-difference-in-both-directions property the pin is built on, demonstrated rather than asserted. The mutation is proved on disk by the anchor counts and the blob hash before the run is read, and the restore bygit hash-objectmatching theHEADblob withgit diff HEADempty.Cross-package type check — the declaration really was rebuilt. A probe compiled against
@objectstack/spec's rebuiltdist/*.d.tsfrom insidepackages/rest:So the green typecheck above read a fresh declaration, not a cache. The probe file was removed and the removal verified.
NormalizedRestServerConfigis module-private torest-server.tsandMetadataEndpointsConfigParsedhas no in-repo consumer, so no downstream package sees the widened parsed shape.Gates —
node scripts/pm/dispatch-gates.mjs --commandsderived 102 families from this diff; all 102 were run and reconciled:Three of them first returned
PREREQUISITE NOT MET(check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt) because they read built output; the workspace was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 71 tasks successful) and all three then returned 0.check-adr-0087-registrationprints1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … registered metadata-endpoints-switch-radius-repartitioned (new here).pnpm --filter @objectstack/spec check:generatedprints✓ All 15 generated artifacts are up to dateaftergen:docsandgen:liveness-counts.Lint — no narrowing was needed: the repo-wide authority ran whole.
pnpm lint(eslint . --no-inline-config)EXIT=0in 58s, re-run on the final tree.Collision check, read as instructed:
scripts/check-single-claim-paths.mjsdeclares exactly one single-writer path,.objectui-sha.packages/rest/src/rest-server.tsis deliberately not on that list (the header's measurement rejects merely-hot files), so that gate can give no signal here — it passes, and its pass is not a clearance. The zero-quota substitute:git ls-remote --heads origin(1182 heads) matches no branch namingrest-server,metadata-endpoint,15854,15544or15851.Known fleet CI defect, not this PR's: if
Test Core (1/6)showscancelledat a 30-minute timeout while the aggregateTest Corereports success, that is #16173 (amplifier #16157). ⛔ Do not re-run it, do not raise the timeout, do not touchscripts/test-shard-timings.json.Generated by Claude Code