Skip to content

fix(rest, runtime): the dispatcher serves the layered view on both spellings, as RestServer does (#20478) - #20505

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20478-dispatcher-meta-layers
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20478-dispatcher-meta-layers

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 answer RestServer gives, 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 through dispatch() against RestServer)

request caller dispatcher RestServer
GET /meta/app/crm?layers=true member 200 {type, name, item} (the plain read), Vary only, no Deprecation 200 {type, name, code, overlay, overlayScope, effective, ...}, Deprecation: true and a Link to /api/v1/meta/app/crm/layers
GET /meta/app/crm?layers=true author (manage_metadata) the plain read, nav pruned to nav_leads every layer whole (nav_leads, nav_finance_ledger)
GET /meta/app/crm/layers both 404 ROUTE_NOT_FOUND ("Route Not Found: /meta/app/crm/layers") the layered answer

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. Everything RestServer's serveMetaItemLayered did after the store read moved there, unchanged:

    1. THE per-caller gate on every present layer, effective first, under STORED_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.
    2. The ADR-0106 mask on every layer through projectMetaObjectSchema, and private, no-store for 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 the Link to the successor when the transport knows the item's path) are shared too.

  • RestServer keeps its read in serveMetaItemLayered: the ingress refusal of a repeated ?package=, its environment, and its own execution-context site. It now takes the organization from metaReadGate.metaReadOrganizationId, which gives the same value as before (the fold over the vetted tenantId). 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 on RestServer. It resolves the mask posture before the capability probe, and answers 501 NOT_IMPLEMENTED with no layered read.
    • ?layers= on the item read: answered first, before either draft switch, as on RestServer. Where the protocol has no layered read, the flag is the plain read.
    • Both spellings go through answerMetaLayered: the read in the caller's vetted partition (metaReadOrganizationId) and ?package= scope, the chain, and this transport's envelope. The flag's Deprecation and Link ride every answer, refusals included, because RestServer sets them before it reads.
    • saveVerdict, the PUT door's admission, moved up to the top of handleMetadataRequest, so the /layers branch asks that same function. It stays inside the same symbol, so the elevation-read census is unchanged.
    • withHeaders is successWithHeaders generalised to any deps.* answer. It is still one hand-built site, and check:route-envelope stays at handBuilt: 2.
  • @objectstack/rest root exports (widening, minor, Clause-②: yes): values createMetaLayeredAnswer, wantsMetaItemLayers, metaItemLayersDeprecationHeaders; types MetaLayeredAnswer, MetaLayeredRequest. @objectstack/runtime stays a patch.

The hypotheses

  • H0: confirmed, as in the table above.
  • H1: confirmed with one adjustment. Every step after the read moved unchanged: the gate, the pruning per ruling B item 2, the mask and the cache posture. The Deprecation / Link pair moved as a shared helper. The read stays in each transport, for two reasons:
    • The Link path is RestServer-only state: its metaPath. The dispatcher's catch-all is handed a path with the host prefix stripped. The dispatcher therefore builds the Link from the request's own URL (createHonoApp hands dispatch() the raw Fetch Request). A host that passes no URL gets Deprecation alone.
    • A first cut put the read inside the seam as well. That removed one this.resolveExecCtx(environmentId, req) site from rest-server.ts, and the existing execctx-consumer-census.test.ts pins that site count at 66 sites and 90 mentions. The chain was reshaped to start after the store read, exactly like createMetaItemAnswer, so that test passes unedited. Both reads take their organization from metaReadOrganizationId.
  • H2: confirmed. layers left the census's ITEM_PARAMS_NOT_SERVED_HERE, and the constant is retired: no exclusion is left. ?layers=true and ?layers= are item probes, derived like every other parameter, and /layers has its own route census derived from RestServer's handler plus serveMetaItemLayered. Every answer compares Deprecation and Link too. The ablations (below) each reddened exactly the layers cells, and each restore is proven.
  • H3: confirmed. The dispatcher's layered read asks metaReadOrganizationId(type, executionContext), the vetted tenantId. 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 1c1b8c809 into the tree only, with blob equality to base shown per file. Census: 110 failed | 539 passed (649). The 110 are:

    • 25 item ?layers=true cells;
    • 75 /layers cells;
    • 8 undetermined-posture layered cells;
    • 2 layered controls.

    Org-scope: 4 failed | 11 passed (15), the 4 layered rows. After the run, the files were restored with git checkout HEAD --: each blob equals HEAD's, git diff HEAD is empty, and git status --porcelain is empty. The first reverse run, on an earlier head against base 45f428d8f (the same four blobs), read the same numbers.

  • Ablations on head 79c967f586, through scripts/ablation-replace.mjs. Each anchor hit once, each mutation landed with a blob change, and each was restored with blob == HEAD and an empty git diff HEAD. The subject is reached through relative imports and the runtime vitest alias to packages/rest/src, so no dist was involved.

    ablation predicted measured
    (A) the dispatcher's ?layers= branch skipped the flag cells only census 28 failed / 621 passed: all 28 are ?layers=true cells (25 item, 2 undetermined, 1 anonymous control). Org 2 failed, the two ?layers=true rows
    (B) the dispatcher's /layers branch removed the route cells only census 82 failed / 567 passed: 75 route cells, 6 undetermined route cells, the 501 control. Org 2 failed, the two /layers rows
    (C) the layered read's organization from the RAW session claim the ex-member rows only census 649 passed. Org 2 failed: exactly the two ex-member rows; both current-member controls stay green
  • Suites.

    • pnpm --filter @objectstack/rest exec vitest run --project local: 218 passed files, 3937 passed / 34 skipped. --project repo: 8 passed. Head 79c967f586; fef1c8e660 changes only runtime comments. No REST test file is edited.
    • pnpm --filter @objectstack/runtime exec vitest run --project local: 285 passed files, 4164 passed / 1 skipped. --project repo: 718 passed. Head fef1c8e660.
    • pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck: exit 0, check:test-typecheck OK 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;
    • six dogfood files: 39 tests, all green (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 merging origin/main at 1c1b8c809:

    • 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-loads first needed 38 missing dist/s built.
      • check:type-check-debt first answered PREREQUISITE NOT MET (exit 3) because the reverse-verification restore left packages/rest sources newer than its dist. It was re-run after pnpm --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.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not 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.

pnpm turbo run build (the runtime closure, the rest and runtime packages, 38 packages for check:dual-build-cjs-loads), pnpm --filter @objectstack/rest test local / repo, pnpm --filter @objectstack/runtime test local / repo, pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck, pnpm lint, pnpm --filter @objectstack/hono --filter @objectstack/http-conformance test, the dogfood file run, the census and org-scope pin runs, the reverse verification and the three ablations

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) answers 404 RESOURCE_NOT_FOUND on the plain read, on /layers and on ?layers=true;
    • GET /meta/app/no_such_app answers 404 on the plain read, but 200 {code: null, overlay: null, effective: null, ...} on /layers and 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 answered 404 / the plain read to both names before, so it now shares the oracle. The fix belongs in createMetaLayeredAnswer, one place for both transports. It changes RestServer'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= Link names the route TEMPLATE. With enableProjectScoping, GET /api/v1/environments/env_1/meta/view/lead_all?layers=true answers a Link naming /api/v1/environments/:environmentId/meta/view/lead_all/layers, with the literal :environmentId. The dispatcher builds its Link from 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 gets Deprecation without a Link. The docblock of requestedItemPath says why.

  • A stale note, not a count: scripts/check-route-envelope.mjs's meta.ts ledger note still describes the second hand-built site as "the /meta/:type list answer". It is now withHeaders, which successWithHeaders delegates 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: RestServer refuses ?package=a&package=b on the layered read. The dispatcher has no such gate, and Hono's catch-all keeps the last value.


Generated by Claude Code

hotlong and others added 7 commits September 29, 2026 03:26
…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>
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 35 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8255a51232e94bbd3e8649217219c34ad1c7f00a.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8255a51232e94bbd3e8649217219c34ad1c7f00a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 10e67ca7bea58df05ceded032248e8315d0d8162 — the merge of head fef1c8e6607cd4826b995ef13f4f93f3f67de0e6 into base 8255a51232e94bbd3e8649217219c34ad1c7f00a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8255a51232e94bbd3e8649217219c34ad1c7f00a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fef1c8e6607cd4826b995ef13f4f93f3f67de0e6
Local-runs: none

Inputs read: card #20478 (body and all 4 comments: triage grade 5875666143, the claim 5876675705, the os-dev-report 5877774656, the seat's answer 5877812166), ruling B on #20156 at its source (5856774816, item 2), PR #20505's body, its 7-file list and the three-dot diff against main (merge base 1c1b8c809, 1265 diff lines), the unchanged helpers those hunks call read at the head through the contents API, and the check-runs on the head. Direction judged against: triage's grade applying ruling B item 2 to the dispatcher — both spellings answer the layered envelope with Deprecation: true through the shared seam, no second implementation, a parity row per form on both transports.

① Derived judgments

Check-runs on the head at my read (20:32 UTC), newest per name, 32 names: 26 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke — opt-in/skip-by-filter), 0 failure, 3 still in_progress: Lint & Repo Gates, Test Core (5/6), Type Check · workspace. Recorded, not inferred. Of the gates this diff bears on: Check Changeset success, Type Check · source gates success, Type Check · consumer gates success, Type Check · debt ledger success, Build Core success, Test Core 1/2/3/4/6 success, Dogfood Regression Gate and Dogfood Verify CLI success, Temporal Conformance success, both single-writer guards and the card-claims-branch guard success. The repo gates the dev cites by name (check:route-envelope, check:system-context-census, check-issue-citations, pnpm lint) ride Lint & Repo Gates, which had not concluded.

(1) RestServer's layered answer is unchanged step by step where it moved into the seam — right. Compared hunk against hunk, with the unchanged helpers read at the head:

  • The gate: old this.metaItemReadGate(environmentId, req, p, metaType, name, docs, RestServer.STORED_VERSION_DOOR_POLICY) is, at the head, createMetaItemReadGate(this.metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), …); RestServer.STORED_VERSION_DOOR_POLICY is metaReadGate.STORED_VERSION_DOOR_POLICY (rest-server.ts:3412), whose app is 'author-exempt', so the old call passed withItemWriteVerdict = true. New: createMetaItemReadGate(this.metaItemReadGateSources(environmentId, req, p, true), metaType, name, docs, STORED_VERSION_DOOR_POLICY) — the same sources, the same constant, metaType still RestServer.metaTypeSingular(req.params.type). Layers judged in the same order (effective, code, overlay), present means != null, every layer judged before any is served, the first refusal returns. Old verdict.send(res) was RestServer.sendMetaReadRefusal(res, refusal); new calls that same static with answer.refusal.
  • The mask: old ran this.maskObjectDocument (= applyObjectSchemaMask, fault on emptied through sendFieldVisibilityFault(res, name)) only under kind === 'project', and set Cache-Control: private, no-store unconditionally under kind === 'undetermined'. New runs projectMetaObjectSchema(posture, doc) on every layer: applyObjectSchemaMask returns the input by reference for every non-project posture and for a null layer (emptied: false), and projectMetaObjectSchema answers cacheControl: 'private, no-store' for every undetermined call, null layer included — so the header is set exactly when it was before, the mask bites exactly where it did, and a mask-fault reaches the same sendFieldVisibilityFault(res, name). The masked input is the gated document, as before (in-place mutation replaced by a copy; the bytes on the wire are the same).
  • The read: unchanged — refuseRepeatedQueryParams(['package']), this.resolveExecCtx(environmentId, req) (the site the exec-ctx consumer census pins is still there), the typed TransportScopedMetaRequest literal with environmentId. layeredOrganizationId moved from organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), layeredCtx?.tenantId) to metaReadGate.metaReadOrganizationId(req.params.type, layeredCtx), which is organizationIdForMetaRead(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)) — the same fold over the same tenantId (identical for a string or absent tenantId, the only values a vetted context carries).
  • The flag: wantsMetaItemLayers(req.query) is the old predicate verbatim (!== undefined && !== ''); metaItemLayersDeprecationHeaders(${metaPath}/${type}/${name}) yields Deprecation: true then the RFC 8288 successor Link with the identical path bytes, in the same header order, set before the read exactly as before.
  • The /layers route registration is untouched (resolve protocol, mask posture on the folded type, then the 501 probe, then the helper). No REST test file is edited: the file list has two test files, both in packages/runtime/src/domains/.

(2) The dispatcher answers both spellings as RestServer does, per-caller pruning and Deprecation included — right, with the differences the PR declares.

  • GET /meta/:type/:name/layers: exactly three segments, GET only, placed after the anonymous deny (metaReadRouteOf names no route for it, so an anonymous caller is denied as on RestServer, where the pre-handler enforceAuth seam denies), resolve protocol → mask posture on pluralToSingular(type) → 501 where getMetaItemLayered is absent → answerMetaLayered. Same order as the REST route.
  • ?layers= on the item read: wantsMetaItemLayers(query) asked first inside the read try, before isDraftRead and previewDrafts act, after the mask posture — where RestServer asks it. Without a layered read the flag falls to the plain read on both, with no headers (pinned).
  • Per-caller pruning: mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed, saveVerdict being the PUT door's metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation: 'save' }) — the same inputs RestServer.metaSaveVerdict(ctx, rawType) feeds (activeOrganizationId: ctx.tenantId). Both hand it in through their metaItemReadGateSources(…, mayWriteItem) on a copy of the context; the chain then applies ruling B item 2 (author whole, everyone else pruned as the plain read prunes). Pinned: author WHOLE on every layer and the plain read pruning the same author to nav_leads; member PRUNED; dashboard widgets per caller only.
  • Envelope mapping is the item read's own: absent → deps.error('Not found', 404) (meta.ts:646 for the plain read), other refusals → deps.error(message, status, { code }), mask-fault → the 503 fault body, a throw → deps.errorFromThrown(e, 500); serve carries Cache-Control only when the chain owes it and no Vary.
  • Deprecation and Link ride every answer of the flag, refusals included, through withHeaders (also on the thrown path) — pinned (payroll 403 carries Deprecation: true), and the census's sameAnswer now compares both headers on every cell of every route.
  • Declared transport differences, judged acceptable and not hidden: Link is built from the request's own URL pathname (requestedItemPath) rather than RestServer's metaPath + params; a host that hands dispatch() no URL gets Deprecation alone (docblock says why, rule 4 cited); byte-equal on the census mount, unmeasured for a percent-encoded name. The repeated ?package= refusal remains RestServer-only, as PR fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) #20404/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. An anonymous ?layers=true on a non-public type is denied before the flag on both transports (no header on either) — consistent, unmeasured by name.

(3) The layered read's organization is the vetted tenantId on both transports — right. Both ask metaReadOrganizationId(rawType, ctx): RestServer with layeredCtx from resolveExecCtx, the dispatcher with _context.executionContext (the resolver #20408 vetted). The org-scope pin drives both spellings through the real identity resolution: current member reads Alpha pipeline on both; the ex-member reads the env-wide All leads on both and the read carries organizationId: undefined on both. Ablation (C) reddened exactly those rows.

(4) Every new @objectstack/rest root export is named in the changeset, minor, Clause-②: yes (widening) — right. index.ts adds exactly three values (createMetaLayeredAnswer, metaItemLayersDeprecationHeaders, wantsMetaItemLayers) and two types (MetaLayeredAnswer, MetaLayeredRequest), removes nothing; the changeset names those five and no other.

Other accept-set and surface changes the diff implies, each judged:

  • Dispatcher HTTP surface: /meta/:type/:name/layers is a route (was a located 404 ROUTE_NOT_FOUND); ?layers= non-empty answers the layered envelope (was the plain read); 501 NOT_IMPLEMENTED where the protocol has no layered read. Right — the direction's two forms, no second implementation (the chain is imported from @objectstack/rest, never restated).
  • The existence oracle (absent name → 200 with every layer null) now reaches the dispatcher. Right for this PR: the chain judges only present layers, so the reference answer is kept — pinned on RestServer by payroll/layers?package=crm → 200 { code: null, effective: null }, and equal across transports by the no_such_app route cells. Closing it is [finding] the layered view (/meta/:type/:name/layers, ?layers=true) answers an absent name 200 with every layer null, where an unpublished app answers 404: an existence oracle against ADR-0045 §3 #20507's, not this diff's.
  • saveVerdict hoisted to the top of handleMetadataRequest: same body, same symbol, the PUT and ?state=draft doors keep asking it; /layers asks the same one. Right.
  • withHeaders generalised from successWithHeaders; fieldVisibilityFaultResponse split out of fieldVisibilityFault. Module-private; @objectstack/runtime gains no export. Right.
  • Census: ITEM_PARAMS_NOT_SERVED_HERE retired with no exclusion left; ?layers=true and ?layers= are item probes; a /layers route census derived from the REST handler plus serveMetaItemLayered; bootRest.read drives the route; a served layered body's code layer is no longer misread as a refusal code. Test-side deltas widen what is compared; nothing is loosened.
  • The [#10340] citation reworded to the folded-type commit, in the phrasing the same file already uses two sites down. Right.

② Semver level

Changeset .changeset/20478-dispatcher-meta-layers.md: @objectstack/rest: minor, @objectstack/runtime: patch. Matches what the diff publishes: the rest root widens by five named exports and narrows nothing → minor; the runtime publishes no new export and its wire change is the dispatcher aligned to the declared contract → patch, the grade PR #20473 (#20408) took for the same shape (dispatcher route added, seam exported from rest). Check Changeset success. Clause-②: yes on the PR body and the claim, Clause-②: yes (widening) in the changeset — consistent, and the claim's amend-to-no clause did not fire.

③ Boundary flags

Dev flags (deviations, 7):

  1. Scratch probe files written inside package dirs and deleted — answered: the file list is the seven claimed files, nothing else.
  2. First cut put the read in the seam and reddened the exec-ctx consumer census; reshaped to start after the read — answered: the resolveExecCtx site stands in serveMetaItemLayered, no REST test edited.
  3. Module-level metaSaveVerdictOf reddened check:system-context-census; hoisted inside handleMetadataRequest — answered in the diff; the gate rides Lint & Repo Gates, in progress at my read.
  4. label-write re-run with the exported token — process only; the PR is assigned hotlong.
  5. Model-free commit trailers — process only, per AGENTS.md.
  6. origin/main advanced after the last merge — answered: head is behind main by 5 at my read, mergeable: true; none of the five touches meta.ts, meta-item-read-gate.ts, rest-server.ts or rest index.ts (objectql, service-analytics, spec, docs, and rest data-door tests only).
  7. Verification ran UNLOCKED — declared verbatim in the PR body; the check-runs on the head are the gate.

open_questions (1): the existence oracle — answered by the seat as A (#20507). This PR keeps the reference answer, as its dispatch required (judged above).

out_of_scope_findings (4): the oracle → #20507; the scoped Link naming the :environmentId template → #20508; the stale check:route-envelope ledger note (count of 2 holds) → carried to the script's next editor, the script being outside this claim — the seat may file a chore card if it prefers a carrier with a number; the repeated-query-parameter gap → pre-existing, recorded in Acceptance notes as PR #20404 and #20473 did.

Nothing to escalate beyond what the seat already filed.

Implemented-by: claude/issue-20478-dispatcher-meta-layers
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 20:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 9449512 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20478-dispatcher-meta-layers branch September 28, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant