Skip to content

Commit 9449512

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest, runtime): the dispatcher serves the layered view on both spellings, as RestServer does (#20478) (#20505)
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 #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](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc07593 commit 9449512

7 files changed

Lines changed: 776 additions & 138 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478)
7+
8+
Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`.
9+
10+
A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any
11+
adapter written on the public `HttpDispatcher` API) serves `/meta` through the
12+
runtime dispatcher. Until now, on such a host:
13+
14+
- **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was
15+
`{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or
16+
`effective` read `undefined`. There was no `Deprecation` header and no `Link`
17+
to the successor. An author (a caller the item's save door admits) was served
18+
the app pruned, where the layered view serves them every layer whole.
19+
- **`GET /meta/:type/:name/layers` was no route.** It answered a located
20+
`404 ROUTE_NOT_FOUND`.
21+
22+
Both spellings now answer what `RestServer` answers: the three layers, each
23+
judged by the per-caller read gate under the stored-version doors' policy
24+
(whole for a caller who may save the item, pruned as the plain read prunes it
25+
for everyone else), each projected through the object-schema field mask, and
26+
`private, no-store` when the caller's field visibility could not be determined.
27+
The read is scoped to the caller's vetted organization and to `?package=`. The
28+
flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to
29+
`/layers` built from the request's own URL (every `createHonoApp` request
30+
carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no
31+
layered read, and the flag is then the plain read, on both transports.
32+
33+
**What changed.** Everything `RestServer`'s layered helper does after the store
34+
read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and
35+
headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The
36+
dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are
37+
unchanged: every existing REST test passes unedited.

‎packages/rest/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,21 +106,31 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
106106
// caller's `/meta` request is scoped to — the VETTED one on its execution
107107
// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read
108108
// of one type).
109+
//
110+
// [#20478] …and the layered view's, on both of its spellings: its post-read
111+
// chain (`createMetaLayeredAnswer` — the per-caller gate on every layer under
112+
// the stored-version doors' policy, the object mask and its cache posture), the
113+
// deprecated `?layers=` flag's parse (`wantsMetaItemLayers`) and the headers it
114+
// is served under (`metaItemLayersDeprecationHeaders`). The read itself is each
115+
// transport's, scoped by `metaReadOrganizationId`.
109116
export {
110117
createMetaBookTreeAnswer,
111118
createMetaItemAnswer,
112119
createMetaItemReadGate,
120+
createMetaLayeredAnswer,
113121
createMetaListReadGate,
114122
createMetaListAnswer,
115123
isPublicAudienceRead,
116124
metaCallerOrganizationId,
125+
metaItemLayersDeprecationHeaders,
117126
metaReadOrganizationId,
118127
metaRequestLocale,
119128
projectMetaObjectSchema,
120129
refuseUnknownMetaListType,
121130
STORED_VERSION_DOOR_POLICY,
122131
translateMetaEnvelope,
123132
translateMetaList,
133+
wantsMetaItemLayers,
124134
} from './meta-item-read-gate.js';
125135
export type {
126136
MetaBookTreeAnswer,
@@ -131,6 +141,8 @@ export type {
131141
MetaItemReadRefusal,
132142
MetaItemReadVerdict,
133143
MetaItemRequest,
144+
MetaLayeredAnswer,
145+
MetaLayeredRequest,
134146
MetaListAnswer,
135147
MetaListAnswerSources,
136148
MetaListRequest,

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
* the list's unknown-type refusal ({@link refuseUnknownMetaListType}), the
5050
* object mask's cache posture ({@link projectMetaObjectSchema}) and the
5151
* organization a caller's read is scoped to ({@link metaReadOrganizationId}).
52+
* [#20478] So does the layered view, on both of its spellings
53+
* ({@link createMetaLayeredAnswer}, {@link wantsMetaItemLayers},
54+
* {@link metaItemLayersDeprecationHeaders}).
5255
* `meta-list-projection-parity.test.ts` and `meta-read-org-scope-parity.test.ts`
5356
* in `@objectstack/runtime` drive both transports over the same fixtures and
5457
* hold the answers equal.
@@ -2448,6 +2451,180 @@ export function createMetaItemAnswer(
24482451
};
24492452
}
24502453

2454+
// ── THE layered answer ────────────────────────────────────────────────────────
2455+
2456+
/**
2457+
* [#5882 · #20478] Does this query ask for the layered view through its
2458+
* DEPRECATED spelling, `GET /meta/:type/:name?layers=<value>`? Any value but
2459+
* the empty string does (`?layers=true`, `?layers=1`, `?layers=false` alike);
2460+
* `?layers=` alone, and no `layers` at all, are the plain read.
2461+
*
2462+
* The one parse both transports ask, so a value cannot be the layered view on
2463+
* one and the plain read on the other. A caller that asks is served the layered
2464+
* view only where the protocol has one (`getMetaItemLayered`); elsewhere the
2465+
* flag is the plain read, as it always was on `RestServer`.
2466+
*/
2467+
export function wantsMetaItemLayers(query: Readonly<Record<string, unknown>> | undefined): boolean {
2468+
return query?.layers !== undefined && query?.layers !== '';
2469+
}
2470+
2471+
/**
2472+
* [#5882 · #20478] The headers every answer of the deprecated
2473+
* `?layers=` spelling carries: RFC 9745 `Deprecation`, and RFC 8288 `Link`
2474+
* naming the successor `GET /meta/:type/:name/layers` — the pairing
2475+
* `versioning.zod.ts` describes for retiring API versions, applied to a
2476+
* retiring query flag. No `Sunset` date: the hard cut-off is a maintainer call,
2477+
* and an invented date is worse than none.
2478+
*
2479+
* `itemPath` is the path the transport serves this item read at — the successor
2480+
* is that path plus `/layers`. A transport that cannot say where it is mounted
2481+
* passes `undefined` and advertises the deprecation alone: a `Link` naming a
2482+
* path this host may not serve is a machine-readable surface that lies (AGENTS.md
2483+
* 〈Route & surface ownership〉 rule 4).
2484+
*/
2485+
export function metaItemLayersDeprecationHeaders(
2486+
itemPath: string | undefined,
2487+
): { Deprecation: 'true'; Link?: string } {
2488+
return itemPath === undefined
2489+
? { Deprecation: 'true' }
2490+
: { Deprecation: 'true', Link: `<${itemPath}/layers>; rel="successor-version"` };
2491+
}
2492+
2493+
/** The layers a layered answer carries, in the order the gate judges them: `effective` first — it is what the plain read serves, so its refusal is the plain read's own. */
2494+
const META_ITEM_LAYERS = ['effective', 'code', 'overlay'] as const;
2495+
2496+
/** The layers in the order the ADR-0106 mask projects them. */
2497+
const META_ITEM_MASKED_LAYERS = ['code', 'overlay', 'effective'] as const;
2498+
2499+
/** The request facts the layered chain reads — no transport shape. */
2500+
export interface MetaLayeredRequest {
2501+
/** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */
2502+
readonly metaType: string;
2503+
readonly name: string;
2504+
/**
2505+
* [ADR-0106 D2/D3] The caller's field-visibility posture for this item,
2506+
* resolved by the transport BEFORE the read, the not-applicable passthrough
2507+
* for every type but `object`. A tier-3 fault is the transport's to answer
2508+
* before it gets here.
2509+
*/
2510+
readonly maskPosture: ObjectSchemaMaskPosture;
2511+
}
2512+
2513+
/**
2514+
* What the layered chain answers:
2515+
*
2516+
* - `serve` — send `layered`, under `cacheControl` when it owes one
2517+
* ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2). No `Vary`:
2518+
* the layered view is not translated;
2519+
* - `refuse` — the gate's {@link MetaItemReadRefusal} for a layer the caller
2520+
* may not read (`absent` included: an unpublished app to a non-builder);
2521+
* - `mask-fault` — a layer's projection left the schema with no field (D6),
2522+
* which the transport answers as its field-visibility fault.
2523+
*/
2524+
export type MetaLayeredAnswer =
2525+
| { kind: 'serve'; layered: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL }
2526+
| { kind: 'refuse'; refusal: MetaItemReadRefusal }
2527+
| { kind: 'mask-fault'; object: string };
2528+
2529+
/**
2530+
* [#5882 · #20156 · #20478] THE answer of the layered view — the three-layer
2531+
* diagnostic projection (`code` / `overlay` / `effective`) declared by
2532+
* `GetMetaItemLayeredResponseSchema` — after the store read, on both of its
2533+
* spellings (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag
2534+
* on the item read), on both transports.
2535+
*
2536+
* ## Why one chain
2537+
*
2538+
* `RestServer` served both spellings through one private helper, and the
2539+
* runtime dispatcher — the only answer on a host that mounts just the
2540+
* `${prefix}/*` catch-all — served neither: the route answered a located
2541+
* `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's
2542+
* `{ type, name, item }` with a `200`, so a client reading `overlay` or
2543+
* `effective` there read `undefined`, and an author was served the app pruned
2544+
* where ruling 5856774816 serves it whole. Everything the helper did after the
2545+
* read moved here, unchanged, and each transport hands its read's answer to
2546+
* THIS function. ⛔ A step is added HERE, never in a transport — one added in
2547+
* one of them is the defect this closed, reopened.
2548+
* `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both
2549+
* spellings through both transports.
2550+
*
2551+
* The read stays each transport's, in the caller's VETTED partition
2552+
* ({@link metaReadOrganizationId} over the folded type — the partition the plain
2553+
* read reads, [#9454] so an author who has just saved an org overlay is not
2554+
* shown `overlay: null`) and its `?package=` scope (ADR-0048), exactly as the
2555+
* item read's does ({@link createMetaItemAnswer}).
2556+
*
2557+
* Not translated and not cached, both deliberately: this is a diagnostic view of
2558+
* what is STORED at each layer, so locale-collapsing it (or serving it from the
2559+
* published-value cache) would misreport the thing being diagnosed.
2560+
*
2561+
* ## The steps, in `RestServer`'s order (unchanged)
2562+
*
2563+
* 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first
2564+
* (it is what the plain read serves, so its refusal is the plain read's
2565+
* own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only
2566+
* (these are STORED versions, which Studio's designer loads and saves
2567+
* back), and ruling 5856774816 — a caller who may write the item reads
2568+
* every layer whole, and any other caller who may open it reads each layer
2569+
* pruned, exactly as the plain read prunes it. ⚠️ So the transport's
2570+
* {@link MetaReadGateAudienceSources.resolveCaller} MUST carry
2571+
* {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission;
2572+
* absent reads as `false` (every caller pruned). Every layer is judged
2573+
* before any is served, so a refusal sends nothing of the others.
2574+
* 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
2575+
* through {@link projectMetaObjectSchema} under the posture resolved before
2576+
* the read, and the `private, no-store` an undetermined posture owes.
2577+
*
2578+
* The protocol's answer is never mutated. A layer the gate or the mask leaves as
2579+
* it was is served as it was, and every other key of the answer
2580+
* (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides
2581+
* through untouched. With nothing behind the name the protocol answers every
2582+
* layer `null`, and so does this chain: no layer is present to judge. A gate
2583+
* input that cannot be read REJECTS: the transport answers that fault, ⛔ never
2584+
* a layered view with a layer missing.
2585+
*/
2586+
export function createMetaLayeredAnswer(
2587+
sources: MetaItemReadGateSources,
2588+
request: MetaLayeredRequest,
2589+
): (layered: unknown) => Promise<MetaLayeredAnswer> {
2590+
const { metaType, name, maskPosture } = request;
2591+
return async (raw) => {
2592+
const layered = raw as Record<string, unknown> | null | undefined;
2593+
2594+
// 1. [#20156] THE per-caller gate, on every present layer.
2595+
const served = new Map<string, unknown>();
2596+
{
2597+
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);
2598+
const judge = createMetaItemReadGate(
2599+
sources, metaType, name, present.map((layer) => layered![layer]), STORED_VERSION_DOOR_POLICY,
2600+
);
2601+
for (const layer of present) {
2602+
const verdict = await judge(layered![layer]);
2603+
if (verdict.kind === 'refuse') return verdict;
2604+
served.set(layer, verdict.document);
2605+
}
2606+
}
2607+
2608+
// 2. [ADR-0106 D5(4)] The mask, on every layer.
2609+
let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined;
2610+
for (const layer of META_ITEM_MASKED_LAYERS) {
2611+
const document = served.has(layer) ? served.get(layer) : layered?.[layer];
2612+
const masked = projectMetaObjectSchema(maskPosture, document);
2613+
if (!masked.ok) return { kind: 'mask-fault', object: name };
2614+
cacheControl ??= masked.cacheControl;
2615+
if (masked.document !== document) served.set(layer, masked.document);
2616+
}
2617+
2618+
let answer: unknown = raw;
2619+
if (layered && typeof layered === 'object') {
2620+
const replaced: Record<string, unknown> = { ...layered };
2621+
for (const [layer, document] of served) replaced[layer] = document;
2622+
answer = replaced;
2623+
}
2624+
return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer };
2625+
};
2626+
}
2627+
24512628
// ── THE book tree ─────────────────────────────────────────────────────────────
24522629

24532630
/** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */

0 commit comments

Comments
 (0)