fix(rest, runtime): the dispatcher serves the layered view on both spellings, as RestServer does (#20478) - #20505
Conversation
…ellings through one seam The layered view (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag on the item read) moves out of `RestServer` into `createMetaLayeredAnswer` in `meta-item-read-gate.ts`, unchanged: the read in the caller's vetted organization and `?package=` scope, the per-caller gate on every layer under the stored-version doors' policy, and the ADR-0106 mask with its cache posture. The flag's parse and its `Deprecation` / `Link` headers are shared too. The runtime dispatcher's `/meta` domain now serves both spellings through it. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ngs, and its org scope is pinned The item census's `?layers=` probe replaces its one declared exclusion, `GET /meta/:type/:name/layers` gets its own route census derived from `RestServer`'s handler and helper, and the answers compare `Deprecation` and `Link` too. The org-scope pins drive both spellings for a current member and for a member whose session claim the resolver dropped. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…d, as the item chain does `createMetaLayeredAnswer` now takes each transport's read answer, like `createMetaItemAnswer`: the read stays in `RestServer`'s helper and in the dispatcher, each scoped by `metaReadOrganizationId`. `RestServer` keeps its own execution-context site there, so the exec-ctx consumer census holds at 66 sites and 90 mentions, unedited. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
… patch Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-layers
…equest, hoisted above every branch The layered read asks the same `saveVerdict` the `PUT` branch and the `?state=draft` read ask; it is now declared once at the top of the handler instead of in a new module-level function, so the elevation-read census anchors it where it already did. Two moved comments now cite a record that resolves. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ead chain, not the whole answer Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 33 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 10e67ca7bea58df05ceded032248e8315d0d8162 && git checkout 10e67ca7bea58df05ceded032248e8315d0d8162
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8255a51232e94bbd3e8649217219c34ad1c7f00a fef1c8e6607cd4826b995ef13f4f93f3f67de0e6 && git checkout -B drift-repro 8255a51232e94bbd3e8649217219c34ad1c7f00a && git merge --no-ff fef1c8e6607cd4826b995ef13f4f93f3f67de0e6
node scripts/docs-audit/affected-docs.mjs --json 8255a51232e94bbd3e8649217219c34ad1c7f00a
|
Contract reviewServed-tier: Inputs read: card #20478 (body and all 4 comments: triage grade ① Derived judgmentsCheck-runs on the head at my read (20:32 UTC), newest per name, 32 names: 26 (1)
(2) The dispatcher answers both spellings as
(3) The layered read's organization is the vetted (4) Every new Other accept-set and surface changes the diff implies, each judged:
② Semver levelChangeset ③ Boundary flagsDev flags (
Nothing to escalate beyond what the seat already filed. Implemented-by: VERDICT: PASS |
Fixes #20478
Clause-②: yes
The runtime dispatcher now serves the layered view on both of its spellings:
GET /meta/:type/:name/layers, and the deprecated?layers=flag on the item read. It gives the answerRestServergives, as ruling B on #20156 (5856774816, item 2) set it, through the shared seam. There is no second implementation:RestServer's layered helper hands its read to the same chain the dispatcher calls.H0, measured first (base
45f428d8f, a scratch probe throughdispatch()againstRestServer)RestServerGET /meta/app/crm?layers=true200 {type, name, item}(the plain read),Varyonly, noDeprecation200 {type, name, code, overlay, overlayScope, effective, ...},Deprecation: trueand aLinkto/api/v1/meta/app/crm/layersGET /meta/app/crm?layers=truemanage_metadata)nav_leadsnav_leads,nav_finance_ledger)GET /meta/app/crm/layers404 ROUTE_NOT_FOUND("Route Not Found: /meta/app/crm/layers")The per-caller pruning from #20156 reproduced as the card describes it: the member is pruned on both layers on
RestServer, and the author is served whole there. The dispatcher's plain read pruned the author too.What changed
The seam (
packages/rest/src/meta-item-read-gate.ts) gains the layered chain,createMetaLayeredAnswer. EverythingRestServer'sserveMetaItemLayereddid after the store read moved there, unchanged:effectivefirst, underSTORED_VERSION_DOOR_POLICY: whole for a caller the save door admits (mayWriteItem), pruned as the plain read prunes for everyone else. Every layer is judged before any is served.projectMetaObjectSchema, andprivate, no-storefor an undetermined posture.The protocol's answer is no longer mutated in place; the chain returns a copy, and the bytes on the wire are unchanged. The flag's parse (
wantsMetaItemLayers: any non-empty value) and its headers (metaItemLayersDeprecationHeaders:Deprecation: true, plus theLinkto the successor when the transport knows the item's path) are shared too.RestServerkeeps its read inserveMetaItemLayered: the ingress refusal of a repeated?package=, its environment, and its own execution-context site. It now takes the organization frommetaReadGate.metaReadOrganizationId, which gives the same value as before (the fold over the vettedtenantId). It then hands the read to the chain. The item handler's flag asks the shared parse and header helpers.The dispatcher (
packages/runtime/src/domains/meta.ts) serves both spellings:GET /meta/:type/:name/layers: exactly three segments, like/published. It keeps the anonymous deny, as onRestServer. It resolves the mask posture before the capability probe, and answers501 NOT_IMPLEMENTEDwith no layered read.?layers=on the item read: answered first, before either draft switch, as onRestServer. Where the protocol has no layered read, the flag is the plain read.answerMetaLayered: the read in the caller's vetted partition (metaReadOrganizationId) and?package=scope, the chain, and this transport's envelope. The flag'sDeprecationandLinkride every answer, refusals included, becauseRestServersets them before it reads.saveVerdict, thePUTdoor's admission, moved up to the top ofhandleMetadataRequest, so the/layersbranch asks that same function. It stays inside the same symbol, so the elevation-read census is unchanged.withHeadersissuccessWithHeadersgeneralised to anydeps.*answer. It is still one hand-built site, andcheck:route-envelopestays athandBuilt: 2.@objectstack/restroot exports (widening,minor,Clause-②: yes): valuescreateMetaLayeredAnswer,wantsMetaItemLayers,metaItemLayersDeprecationHeaders; typesMetaLayeredAnswer,MetaLayeredRequest.@objectstack/runtimestays apatch.The hypotheses
Deprecation/Linkpair moved as a shared helper. The read stays in each transport, for two reasons:Linkpath isRestServer-only state: itsmetaPath. The dispatcher's catch-all is handed a path with the host prefix stripped. The dispatcher therefore builds theLinkfrom the request's own URL (createHonoApphandsdispatch()the raw FetchRequest). A host that passes no URL getsDeprecationalone.this.resolveExecCtx(environmentId, req)site fromrest-server.ts, and the existingexecctx-consumer-census.test.tspins that site count at 66 sites and 90 mentions. The chain was reshaped to start after the store read, exactly likecreateMetaItemAnswer, so that test passes unedited. Both reads take their organization frommetaReadOrganizationId.layersleft the census'sITEM_PARAMS_NOT_SERVED_HERE, and the constant is retired: no exclusion is left.?layers=trueand?layers=are item probes, derived like every other parameter, and/layershas its own route census derived fromRestServer's handler plusserveMetaItemLayered. Every answer comparesDeprecationandLinktoo. The ablations (below) each reddened exactly the layers cells, and each restore is proven.metaReadOrganizationId(type, executionContext), the vettedtenantId. The org-scope pins drive both spellings through the REAL identity resolution on both transports. The raw-claim ablation below reddens exactly the ex-member rows.Evidence
Reverse verification. The final tests were run against the base sources: the four source files were restored from the merge base
1c1b8c809into the tree only, with blob equality to base shown per file. Census:110 failed | 539 passed (649). The 110 are:?layers=truecells;/layerscells;Org-scope:
4 failed | 11 passed (15), the 4 layered rows. After the run, the files were restored withgit checkout HEAD --: each blob equalsHEAD's,git diff HEADis empty, andgit status --porcelainis empty. The first reverse run, on an earlier head against base45f428d8f(the same four blobs), read the same numbers.Ablations on head
79c967f586, throughscripts/ablation-replace.mjs. Each anchor hit once, each mutation landed with a blob change, and each was restored with blob ==HEADand an emptygit diff HEAD. The subject is reached through relative imports and the runtime vitest alias topackages/rest/src, so nodistwas involved.?layers=branch skipped28 failed / 621 passed: all 28 are?layers=truecells (25 item, 2 undetermined, 1 anonymous control). Org2 failed, the two?layers=truerows/layersbranch removed82 failed / 567 passed: 75 route cells, 6 undetermined route cells, the 501 control. Org2 failed, the two/layersrows649 passed. Org2 failed: exactly the two ex-member rows; both current-member controls stay greenSuites.
pnpm --filter @objectstack/rest exec vitest run --project local:218 passedfiles,3937 passed / 34 skipped.--project repo:8 passed. Head79c967f586;fef1c8e660changes only runtime comments. No REST test file is edited.pnpm --filter @objectstack/runtime exec vitest run --project local:285 passedfiles,4164 passed / 1 skipped.--project repo:718 passed. Headfef1c8e660.pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck: exit 0,check:test-typecheckOK on both, after building the closure (pnpm turbo run build --filter='@objectstack/runtime...').Consumers the dispatcher's wire change reaches:
@objectstack/hono: 5 files, 122 tests;@objectstack/http-conformance: 8 files, 102 tests;showcase-object-extension-meta-read,showcase-object-extension-scalar-divergence,multi-package-artifact,meta-published-and-state-routes,route-ledger-live-mount-parity,dashboard-designer-roundtrip).Gates, on head
fef1c8e660, after mergingorigin/mainat1c1b8c809:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 62 commands derived, every one run on this head.--ran:62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3).check:dual-build-cjs-loadsfirst needed 38 missingdist/s built.check:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3) because the reverse-verification restore leftpackages/restsources newer than itsdist. It was re-run afterpnpm --filter @objectstack/rest build: exit 0.pnpm lint(eslint . --no-inline-config, the whole repo): exit 0.node scripts/check-issue-citations.mjs --base origin/main: exit 0. A first run named two moved comments whose cited cards are gone from the board (#10340,#12195); those comments were reworded.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
Acceptance notes
Out of scope, measured (class b, ADR-0045 §3): the layered view is an existence oracle for an unpublished app. Measured as a member through
RestServer's route handlers:GET /meta/app/launchpad(an unpublished app) answers404 RESOURCE_NOT_FOUNDon the plain read, on/layersand on?layers=true;GET /meta/app/no_such_appanswers404on the plain read, but200 {code: null, overlay: null, effective: null, ...}on/layersand on?layers=true.So a non-builder can tell that an unpublished app exists, which ADR-0045 §3 rules "externally unobservable". This PR carries
RestServer's answer onto the dispatcher, as the triage direction requires. The dispatcher answered404/ the plain read to both names before, so it now shares the oracle. The fix belongs increateMetaLayeredAnswer, one place for both transports. It changesRestServer's reference answer for an absent name, so it is left for its own card.Seam: spec:GetMetaItemLayeredResponseSchema → runtime:createMetaLayeredAnswer (packages/rest/src/meta-item-read-gate.ts).Out of scope, measured (class a):
RestServer's scoped?layers=Linknames the route TEMPLATE. WithenableProjectScoping,GET /api/v1/environments/env_1/meta/view/lead_all?layers=trueanswers aLinknaming/api/v1/environments/:environmentId/meta/view/lead_all/layers, with the literal:environmentId. The dispatcher builds itsLinkfrom the request's URL, so it names the real path; the census drives the unscoped mount, where the two are byte-equal.A transport difference kept on purpose: a host that hands
dispatch()a request with no URL getsDeprecationwithout aLink. The docblock ofrequestedItemPathsays why.A stale note, not a count:
scripts/check-route-envelope.mjs'smeta.tsledger note still describes the second hand-built site as "the /meta/:type list answer". It is nowwithHeaders, whichsuccessWithHeadersdelegates to. The count (2) holds and the gate is green. The script is not in this claim; its next editor carries it.Repeated query parameters are still unchanged here, as PR fix(rest, runtime): the dispatcher's /meta doors scope to the vetted organization, and its item read, book tree and list answer what RestServer answers (#20408) #20473 recorded:
RestServerrefuses?package=a&package=bon the layered read. The dispatcher has no such gate, and Hono's catch-all keeps the last value.Generated by Claude Code