From 6fa5946df966fbfc46b4442b5d2f13657fef7c84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:11:44 +0000 Subject: [PATCH 1/5] refactor(rest): move the /meta per-item reads into meta-item-read-routes.ts Sub-slice 13b, the move commit. The fourth /meta group of RestServer#registerMetadataEndpointsInner, one `if (metadata.endpoints.item !== false)` statement registering GET {metaPath}/:type/:name/references, /layers, GET {metaPath}/book/:name/tree and GET {metaPath}/:type/:name, moves re-indented to the new module as registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: MetaRouteScope). The inner method builds the itemReadHost literal at the statement's old position, with routeManager and STORED_VERSION_DOOR_POLICY by value, and passes the one existing scope. The four private-static receivers become this.X through the host. The two body-only helpers move with the body; sendMetaItemAbsent and stampAnonymousFormIntakeWarnings gain export. The readers follow in later commits. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/rest/src/meta-item-read-routes.ts | 1199 ++++++++++++++++++++ packages/rest/src/rest-server.ts | 1076 +----------------- 2 files changed, 1254 insertions(+), 1021 deletions(-) create mode 100644 packages/rest/src/meta-item-read-routes.ts diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts new file mode 100644 index 00000000000..5f398b04809 --- /dev/null +++ b/packages/rest/src/meta-item-read-routes.ts @@ -0,0 +1,1199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 13b] The `/meta` per-item reads of `RestServer` — the reverse + * reference sweep (`GET {metaPath}/:type/:name/references`), the layered read + * (`GET {metaPath}/:type/:name/layers`), the book tree + * (`GET {metaPath}/book/:name/tree`) and the item read itself + * (`GET {metaPath}/:type/:name`) — moved out of `rest-server.ts`. They were the + * fourth group of `RestServer#registerMetadataEndpointsInner`'s body, ONE `if` + * statement on the per-item switch, and that method now calls the function below + * at its old position, so the `/meta` routes still register in one order: after + * the per-type list, and with `/references` and `/layers` still above the item + * read they share a prefix with. + * + * A MOVE, not a redesign. The function below is that statement as it stood, + * re-indented one level, its switch (`metadata.endpoints.item`) included: the + * same four routes per base, by the same handlers, answering the same bodies. + * The rest of the per-item face — the item writes and the history family — + * stays in the inner method and still registers behind the same switch, which + * that method spells as a call to its own per-item registrar. The moved + * comments are moved text too: where one says "this block's closing brace" or + * "below", it still describes the inner method's order, in which this statement + * sits immediately above those writes. + * + * ## How the moved body reaches the class + * + * The shape slice 13a set for `/meta` (`meta-collection-routes.ts`' header + * carries the full reasoning, and `data-action-routes.ts`' the shape it came + * from). The class keeps the private method, and the method keeps its head; at + * the position of the moved statement it builds one host literal + * ({@link MetaItemReadRouteHost}) and hands it, with the head's one + * {@link MetaRouteScope}, to the function below as its `this` and its argument. + * A method is handed over as a closure that calls it on the server at call + * time, so a test double or spy set on the server is still the one the body + * reaches. + * + * Each member was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE, and the literal that carries it is built + * INSIDE `registerMetadataEndpointsInner`, at the position of the statement + * it replaces. That placement is the load-bearing part. + * `registerMetadataEndpoints`, the umbrella, swaps the server's route + * manager for a wrapping registrar for exactly the duration of the inner + * call and restores it in a `finally`; the wrapper is what puts every + * `/meta` route behind the anonymous-deny gate (#3963) and the type-level + * read and write admissions (#21087, #21124). The body reads the manager + * only for its four `register` calls, all of which run during that call, and + * nothing inside the inner method reassigns it, so the value captured there + * is the object every one of those reads returned. ⛔ A host built anywhere + * else — in `registerRoutes`, in the constructor, cached on the instance, or + * in the umbrella above the swap — captures the REAL manager, and these four + * routes would mount outside all three gates. + * - `STORED_VERSION_DOOR_POLICY` goes BY VALUE too, and it is the first + * static FIELD a host carries. It is the class's `private static readonly` + * field, assigned once, when the class is evaluated, from the constant of + * the same name in `meta-item-read-gate.ts`, and never reassigned, so the + * value the literal captures is the object every read of it returned. + * - No member goes as a getter, because the body reads no instance FIELD at + * request time. The protocol, the caller, the masker, the locale, the i18n + * service, the read gate, the answer sources and the translation are each + * resolved per request by a METHOD of the class, and every method goes as a + * closure. No provider field is read. + * + * The inner method's head declares the bindings every `/meta` group reads and + * hands them over as ONE parameter, {@link MetaRouteScope}, built once there; + * the function destructures the three its body reads (`metadata` for the + * switch and the cache flag, `metaPath`, `isScoped`). The host stays class + * members only. + * + * ## The four tokens that are not byte-identical + * + * The body reached three of the class's PRIVATE STATICS as `RestServer.…`: the + * type normalizer `metaTypeSingular` (once in the layered read, once in the + * item read), and, in the item read's stored-version branch, the field + * `STORED_VERSION_DOOR_POLICY` and the refusal emitter `sendMetaReadRefusal`. + * A private static is reachable by name only inside the class, so that + * spelling does not compile here, and making a member public, or moving it, + * changes the published declaration the move must keep byte-identical. So + * each is reached the way every other class member this body reads is + * reached: through the host, as `this.…` — the two methods as closures on + * {@link MetaItemReadRouteHost} that call the static with the same + * arguments, the field by value. Neither method reads anything but its + * arguments, so the receiver is the whole difference, as it was for slice + * 13a's `metaTypeSingular` and slice 11's `translateOptionsFor`. Those four + * receivers are the only tokens of the body that changed. + * + * ## Module-level names + * + * The two module-level helpers only this body called moved with it, verbatim, + * and sit at the end of this file: the reference sweep's declared-refusal + * re-dresser and the intake reasons' ETag dimension. Five names declared in + * `rest-server.ts` stay there and are imported from it: the reference sweep's + * closed query set (its tests import it from there), the package binding and + * the draft admission (both already exported), and the absence answer and the + * intake-reason stamp, which the class reads too and which gained `export` + * there rather than a second home. Every one of them is read only at request + * time, so the two modules importing each other cannot see one uninitialised. + * The imports only this body read left `rest-server.ts` with it; every other + * name is imported from the module `rest-server.ts` imports it from. The + * package entry re-exports nothing from here, and the published surface does + * not move. + */ + +import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; +// [ADR-0106 / #3682] Metadata-plane FLS — the item read's half of it; see +// `rest-server.ts`' import of the same module for why the normalizer lives in +// `@objectstack/metadata-core`. +import { + ObjectSchemaMaskEvaluationError, + foldVisibilityFingerprintIntoEtag, + normalizeIfNoneMatch, + relateObjectSchemaMaskPosture, + resolveObjectSchemaRuntimeView, + type ObjectSchemaMaskPosture, + // [#21476] The ADR-0106 fingerprint the admin read folds the reason into + // — and [#22639] the list views the read gate withheld. + objectFieldVisibilityFingerprint, +} from '@objectstack/metadata-core'; +import type { GetMetaItemCachedRequest, GetMetaItemRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; +import { + boundedDeclaredRefusalMessage, + declaredHttpStatus, + handleRouteError, + sendDeclaredFault, + sendFieldVisibilityFault, + thrownAnswerIsBareNotFound, +} from './error-response.js'; +import type { MetaRouteScope, TransportScopedMetaRequest } from './meta-collection-routes.js'; +import * as metaReadGate from './meta-item-read-gate.js'; +import { META_ITEM_LAYERS_PARAMS, metaMemberSelector, type MetaReadGatePolicy } from './meta-item-read-gate.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import { + META_ITEM_REFERENCES_PARAMS, + mayReadPendingDrafts, + metaItemPackageBinding, + sendMetaItemAbsent, + stampAnonymousFormIntakeWarnings, + type RestServer, +} from './rest-server.js'; + +/** + * The `RestServer` members {@link registerMetaItemReadRoutes} reads — every + * one of them, and no other, in the order the body first reads them. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's signature on the class is a change here too, and naming a member the + * class does not have is a compile error. The three statics are typed as the + * statics they are. The module header says why `routeManager` is captured + * inside the swap and by value, why `STORED_VERSION_DOOR_POLICY` goes by value, + * why the body reaches the statics through the host, and why none of the + * members is a getter. + */ +export interface MetaItemReadRouteHost { + readonly routeManager: RestServer['routeManager']; + resolveProtocol: RestServer['resolveProtocol']; + metaTypeSingular: (typeof RestServer)['metaTypeSingular']; + resolveObjectMasker: RestServer['resolveObjectMasker']; + serveMetaItemLayered: RestServer['serveMetaItemLayered']; + metaReadAudienceSources: RestServer['metaReadAudienceSources']; + extractLocale: RestServer['extractLocale']; + resolveExecCtx: RestServer['resolveExecCtx']; + resolveI18nService: RestServer['resolveI18nService']; + metaItemReadGate: RestServer['metaItemReadGate']; + maskObjectDocument: RestServer['maskObjectDocument']; + anonymousFormIntakeWarnings: RestServer['anonymousFormIntakeWarnings']; + translateMetaEnvelope: RestServer['translateMetaEnvelope']; + readonly STORED_VERSION_DOOR_POLICY: (typeof RestServer)['STORED_VERSION_DOOR_POLICY']; + metaItemAnswerSources: RestServer['metaItemAnswerSources']; + sendMetaReadRefusal: (typeof RestServer)['sendMetaReadRefusal']; +} + +/** + * Register the `/meta` per-item reads — the reference sweep, the layered read, + * the book tree and the item read, the fourth group of + * `RestServer#registerMetadataEndpointsInner`' body, called by that method at + * its position in its registration order, with the server's members as `this` + * and the method's head bindings as `scope`. The method's own docblock, which + * the published declaration carries, names the families, their switches and + * the ordering facts the routes below keep. + */ +export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: MetaRouteScope): void { + const { metadata, metaPath, isScoped } = scope; + + // GET /meta/:type/:name - Get specific item + // + // [#15542 / #15854] The first four members of the per-item face. The + // rest of it — `PUT`, `DELETE` and the history family — is registered + // below this block's closing brace and goes through + // {@link registerPerItemRoute}, which carries the SAME switch. ⛔ The + // brace is not the radius: read the pin table in + // `rest-config-mount-table.pin.test.ts` for what `item` gates. + if (metadata.endpoints.item !== false) { + // Phase 3a-references: /meta/:type/:name/references must be + // registered BEFORE /meta/:type/:name so the more-specific + // path wins under any first-match router strategy. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/:type/:name/references`, + handler: async (req: any, res: any) => { + try { + // [#23072] Recognition first, over the measured closed + // set — empty: the sweep reads no query parameter. + if (refuseUnknownQueryParams(req, res, META_ITEM_REFERENCES_PARAMS)) return; + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).findReferencesToMeta !== 'function') { + // [#9326 / ADR-0110 D3] A MISS and a FAULT are + // different facts, and this branch is the second + // one: the resolved protocol cannot compute the + // reference graph AT ALL, so the question was never + // asked. Answering `{ references: [] }` reported it + // as the first — "nothing depends on this item" — + // and the admin "Used by" panel renders that empty + // case as "Nothing in the metadata graph points at + // this item. Safe to delete.", shown to an operator + // about to delete something. + // + // Refusing HERE rather than asserting at assembly is + // deliberate. `findReferencesToMeta` is not a member + // of `RestProtocol` (= `DataProtocol & + // MetadataProtocol`) — it is an ADR-0076 D9 + // server-only extension, which is why it is reached + // through a runtime cast at all. So a host that + // implements the DECLARED contract exactly is a + // CONFORMING deployment that lands here with no type + // error, and a boot-time assertion would promote an + // undeclared optional extension into a required one + // — a `packages/spec` contract decision, not a + // route one. What the route owes is that an + // unanswerable question comes back unanswered. + // + // Envelope per #7035: the ADR-0112 NESTED + // `{ error: { code, message } }` that the sibling + // `/meta` 501 refusals converged on — never the + // bare-string or sibling-`code` dialects, which make + // `body.error.code` read `undefined`. + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'protocol.findReferencesToMeta() is not available in this kernel', + }, + }); + return; + } + // [#13753 · ADR-0131 D6] The sweep reads the sources + // environment → code, the world the `/meta` doors serve: + // the per-organization overlay axis is retired, so no + // organization is stated. A legacy organization-scoped + // source row is served by no door until ADR-0131 C7 + // promotes it, and that ceremony re-judges what it + // carries; it is not swept here. + // [#15685] The protocol's OWN refusal is re-answered in + // the nested envelope the branch above already uses — + // see {@link notImplementedRefusalAnswer} for why the + // repair is here and how narrow it is. The catch is + // scoped to the protocol call ALONE, so what this arm + // can re-dress is mechanically the set of things + // `findReferencesToMeta` raised: neither + // `resolveProtocol` nor the `resolveExecCtx` seam above + // can reach it, whatever they declare. + let result: unknown; + try { + result = await (p as any).findReferencesToMeta({ + type: req.params.type, + name: req.params.name, + ...(environmentId ? { environmentId } : {}), + }); + } catch (raised: any) { + const refusal = notImplementedRefusalAnswer(raised); + // Anything else is the outage it always was — the + // 503 `getMetaItems` raises for a `sys_metadata` + // failure (#8896) still propagates to the terminal + // below, message-withheld and logged, unchanged. + if (refusal === undefined) throw raised; + res.status(refusal.status).json(refusal.body); + return; + } + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List metadata items that reference this item', + tags: ['metadata'], + }, + }); + + // [#5882] GET /meta/:type/:name/layers — the three-layer diagnostic + // projection as its OWN resource. Registered BEFORE + // /meta/:type/:name for the same first-match reason as + // /references above. [commit 7986d973f] It also used to have to precede the + // compound `/:type/:section/:name`, which would otherwise capture + // this path with section=, name="layers"; that catch-all is + // retired, so only the /references-style reason remains. + // + // This path exists because the projection used to be reachable only + // as `GET /meta/:type/:name?layers=true` — the same route answering + // a SECOND, undeclared body shape depending on a query flag, while + // `packages/spec` declared one `responseSchema` for it. The ruled + // fix (maintainer, 2026-08-06) was one path per response shape, + // deliberately NOT teaching the route declaration to express + // "two shapes chosen by a flag": that would add a primitive every + // future tool has to understand, and conditional response selection + // is exactly where codegen and AI-written clients go wrong. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/:type/:name/layers`, + handler: async (req: any, res: any) => { + try { + // [#23072] Recognition first, over the measured closed + // set; `serveMetaItemLayered` keeps the multiplicity + // rule it shares with the deprecated `?layers=` spelling. + if (refuseUnknownQueryParams(req, res, META_ITEM_LAYERS_PARAMS)) return; + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + // [ADR-0106 D2/D5] The dedicated path is its own + // schema-serving outlet — it resolves the caller's + // field-visibility posture exactly like the plain meta + // read does, with the NORMALIZED type (#3984 / commit 83a3b1f2e). + const layeredMetaType = this.metaTypeSingular(req.params.type); + let maskPosture: ObjectSchemaMaskPosture; + try { + maskPosture = await (await this.resolveObjectMasker(environmentId, req, layeredMetaType))(req.params.name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, req.params.name); + return; + } + throw maskError; + } + if (typeof (p as any).getMetaItemLayered !== 'function') { + // A dedicated path cannot fall through to the plain + // read the way the `?layers=` flag did — answering + // the merged `{ type, name, item }` envelope here + // would be answering a different resource with a + // shape this path never declares. + res.status(501).json({ + error: 'Layered metadata view not supported by protocol implementation', + code: 'NOT_IMPLEMENTED', + }); + return; + } + await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Get a metadata item as its three layers (code / overlay / effective)', + tags: ['metadata'], + }, + }); + + // ADR-0046 §6 — GET /meta/book/:name/tree + // Resolve a book spine against the docs that exist *now* into a + // rendered tree (membership is DERIVED, never stored — §6.2.1). An + // unknown name is treated as a package id and resolved against the + // implicit per-package book (§6.4). Anonymous requests only see a + // book whose `audience` is `public` (§6.7 read-layer gating). + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/book/:name/tree`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const prot = await this.resolveProtocol(environmentId, req); + // [#6877] One package scopes the book lookup. [#23214] The + // list lives beside the shared decision, as the list door's. + if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS['book-tree'])) return; + // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. + const packageId = metaItemPackageBinding(req.query?.package); + // [#20408] The route's whole answer is + // `createMetaBookTreeAnswer` in `./meta-item-read-gate.ts` + // — the book and doc reads, THE `DocsAudience` (#19790: one + // resolution, four doors), the §6.7 gate on the book's own + // audience, the doc locale collapse and the tree narrowed + // per caller — which the runtime dispatcher serves this + // route through too. ⛔ A step is added there, never here. + const answer = await metaReadGate.createMetaBookTreeAnswer( + { + ...this.metaReadAudienceSources(environmentId, req), + listTreeInput: (type, scopedPackageId) => { + const request: TransportScopedMetaRequest = { + type, + ...(scopedPackageId ? { packageId: scopedPackageId } : {}), + ...(environmentId ? { environmentId } : {}), + }; + return prot.getMetaItems(request); + }, + requestLocale: (i18n) => this.extractLocale(req, i18n), + }, + { name: req.params.name, packageId }, + ); + if (!answer.ok) { + const { code, message, status } = answer.refusal; + sendDeclaredFault(res, { code, message, status }); + return; + } + res.json(answer.tree); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Resolve a documentation book spine into its rendered tree (ADR-0046 §6)', + tags: ['metadata'], + }, + }); + + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/:type/:name`, + handler: async (req: any, res: any) => { + try { + // [#6877] Declared at the top for the same reason #3984 + // normalizes `:type` here: this handler reads its query + // parameters from four different branches hundreds of + // lines apart (`?layers=`, `?state=`, `?preview=`, + // `?package=`), and a per-branch gate is one a new branch + // inherits by accident rather than by construction. + // [#22877] …and RECOGNITION first, over the route's + // measured closed set (AGENTS.md route rule 5), which + // the bundle member selector joined. + // [#23072] All three of the route's query rules — + // recognition, arity and a member on the layered view — + // are ONE decision, `metaItemQueryRefusal` in + // `./meta-item-read-gate.ts`, which the runtime + // dispatcher's item read asks too (ruling 6138588561). + // The first two are answered here, in the nested body + // their gates always wrote; the third where the layered + // view is chosen, below. ⛔ A rule is added there, never + // here. + const queryRefusal = metaReadGate.metaItemQueryRefusal('item', req.query); + if (queryRefusal && queryRefusal.rule !== 'layered-member') { + res.status(queryRefusal.status).json({ + error: { code: queryRefusal.code, message: queryRefusal.message }, + }); + return; + } + const memberSelector = metaMemberSelector(req.query); + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + + // [#3984 / commit 83a3b1f2e] Normalize the `:type` segment ONCE, + // here at the top, and let every gate below read THIS + // value. The route serves both spellings and Prime + // Directive #3 makes the plural one canonical + // (`/meta/books/:name`), so any gate comparing the raw + // param is a gate the canonical spelling walks past. + // + // #3984 ruled this shape for exactly that reason ("每个 + // handler 顶部归一一次,后续所有闸门都用归一后的值"), and + // commit 83a3b1f2e is why the ruling is written into the code + // rather than trusted to memory: eight days after + // #3984 landed, the cache-branch condition below still + // excluded `doc`/`book` by LITERAL comparison, so + // `GET /meta/books/:name` took the cached branch and + // the §6.7 audience gate — which lives in the uncached + // branch — never ran at all. Measured on the real + // server, one `{ permissionSet }`-gated book, one + // signed-in caller holding no set: + // + // singular "book" :: cachedCalls=0 status=[403] + // plural "books" :: cachedCalls=1 status=[] ← full body served + // + // A new per-type gate added below inherits the + // normalization by default now; there is no raw param + // in scope for it to compare against by accident. + const metaType = this.metaTypeSingular(req.params.type); + + // [ADR-0106 D2/D5] Resolve the caller's field-visibility + // posture ONCE, here, before any fetch — every exit + // below (layered, cached, uncached) projects through + // THIS value. Resolving per-branch is how an outlet gets + // forgotten; resolving before the fetch is what makes + // D3's `fetch → mask → send` ordering structural rather + // than a convention. + let maskPosture: ObjectSchemaMaskPosture; + try { + maskPosture = await (await this.resolveObjectMasker(environmentId, req, metaType))(req.params.name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, req.params.name); + return; + } + throw maskError; + } + + // Phase 3a-layered-get: opt-in 3-state view when client + // asks for `?layers=true` (or any non-empty value). + // Skips the cache path entirely — layered view is a + // diagnostic endpoint, not on the hot read path. + // + // [#5563 → #5882] DEPRECATED SPELLING. This flag makes one + // route answer a SECOND resource representation — three + // layers side by side (`code` / `overlay` / `effective`), + // where `effective` is what the plain read returns — while + // the route declares a single `responseSchema`. #5563 + // converged the ordinary read and left this half open + // because collapsing the layers into + // `GetMetaItemResponseSchema`'s single `item` would delete + // the diagnostic outright. + // + // #5882 closed it the other way (maintainer ruling, 2026-08-06): + // the projection is now its own path, + // `GET /meta/:type/:name/layers`, declared by + // `GetMetaItemLayeredResponseSchema`. One path, one shape. + // + // This branch stays for a deprecation window so existing + // callers (Studio's metadata editor) are not broken by the + // move. It answers the IDENTICAL body — same helper, not a + // copy — and advertises the successor in the response + // headers, so a client can discover the migration without + // reading the changelog. Delete this branch (and the + // headers with it) once the callers have moved. + // + // [#20478] The flag's parse (`wantsMetaItemLayers`) and its + // headers (`metaItemLayersDeprecationHeaders`: RFC 9745 + // `Deprecation` + RFC 8288 `Link` to the successor) are the + // ones the runtime dispatcher's item read asks too, so the + // deprecated spelling is one answer on both transports. + // + // [#20508] The `Link` names the path THIS request arrived + // on (`IHttpRequest.path`), read the way the dispatcher + // reads its request URL (`requestedItemPath`, runtime + // `domains/meta.ts`): parsed as a URL path, without its + // trailing slash. ⛔ Never `metaPath` — on the + // environment-scoped mount that is the route TEMPLATE, and + // the successor read `/environments/:environmentId/…/layers`, + // a path no client can request. The parse is what keeps the + // path a valid URI reference: the Hono adapter hands over a + // `decodeURI`'d path (`lead%20all` arrives as `lead all`), + // and the parse percent-encodes it again. A request with no + // path names no successor: `Deprecation` alone, as the + // helper prescribes for a transport that cannot say where + // it serves the item. + const wantLayered = metaReadGate.wantsMetaItemLayers(req.query); + // [#22877] The layered view reads no member, so a member + // named on it is refused rather than answered with the + // canonical member's layers. [#23072] The decision's + // third rule (`layered-member`), the only one that can + // still be pending here; thrown to `handleRouteError` as + // it always was. + if (queryRefusal) { + throw Object.assign(new Error(queryRefusal.message), { + code: queryRefusal.code, + status: queryRefusal.status, + }); + } + if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { + const requestPath: unknown = req.path; + const deprecation = metaReadGate.metaItemLayersDeprecationHeaders( + typeof requestPath === 'string' && requestPath.startsWith('/') + ? new URL(`http://rest-server.invalid${requestPath}`).pathname.replace(/\/+$/, '') || undefined + : undefined, + ); + for (const [header, value] of Object.entries(deprecation)) res.header(header, value); + await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); + return; + } + + // Check if cached version is available. + // For `app` metadata we skip the cache path so the + // per-user RBAC filter below can apply without + // corrupting shared ETags across admin vs member + // viewers of the same app schema. Drafts also + // bypass cache: the cache is keyed on the + // published checksum and drafts are out-of-band. + const isAppType = metaType === 'app'; + // [#20338] The caller, resolved ABOVE the two draft + // switches because each is admitted per caller. + // Memoised per request — not a new seam. + const readCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + // [#20338] Both draft switches are declared WITH their + // admission ({@link mayReadPendingDrafts}). A caller who + // may not read drafts is answered this read as if + // neither switch were present — the published item, + // pruned as the plain read prunes it, or its absence — + // and no later branch re-reads `?state=` past the gate + // (the uncached arm used to parse it a second time). + const isDraftRead = typeof req.query?.state === 'string' + && req.query.state.toLowerCase() === 'draft' + && mayReadPendingDrafts(readCtx); + // ADR-0033/0037 — `?preview=draft` overlays a pending + // draft on the active item (draft wins, falls back to + // active). Must also bypass the cache: ETags are keyed + // on the published checksum, so a cached 304 would pin + // the preview to the stale published world. + const previewDrafts = typeof req.query?.preview === 'string' + && req.query.preview.toLowerCase() === 'draft' + && mayReadPendingDrafts(readCtx); + // ADR-0048 — a `?package=` read is package-scoped + // (prefer-local). The cached path keys ETags on + // type+name only and does NOT thread `packageId` into + // `getMetaItemCached`, so two installed packages shipping + // the same type/name would share one cache entry and the + // scope hint would be silently dropped. Bypass the cache + // when a package scope is requested so the disambiguating + // `getMetaItem(type, name, packageId)` path runs. + const packageScoped = typeof req.query?.package === 'string' + && req.query.package.length > 0; + // `doc` and `book` bypass the shared cache: their §6.7 + // audience gate is per-caller, and a shared ETag would + // leak gated content across viewers. + // + // [commit 83a3b1f2e] That sentence was already here while the + // exclusion beneath it compared the RAW param against + // the literals `'doc'` / `'book'`, so the canonical + // plural spelling took the cached branch and shipped + // the gated body. The exclusion is not incidental + // tidying — it is the stated security invariant above, + // and it now reads the normalized `metaType`. + // + // The bypass exists only to make the per-caller gate in + // the uncached branch reachable ({@link metaItemReadGate}, + // whose `book` / `doc` arm is the §6.7 gate). [#20156] + // A type that gate judges and this exclusion misses is + // served ungated from the cache: the census in + // `meta-alternate-door-read-gates.test.ts` drives this + // read with a cached protocol method present, so that + // miss reddens its plain-read row. + // + // [#5881] `dashboard` bypasses it too, and the reason is + // NOT the one above — worth writing down, because the + // obvious reading says a dashboard needn't bypass at all. + // Its ADR-0057 D10 widget gate (`filterDashboardForUser`, + // below) is per-DEPLOYMENT — it asks which optional kernel + // services are registered — never per-caller, so there is + // no cross-viewer leak to avoid. What rules out sharing + // the cached path is the validator itself: the ETag is + // `simpleHash(locale + JSON.stringify(item))` over the + // UNFILTERED document (metadata-protocol `getMetaItemCached`), + // so it cannot express the gate dimension at all, and + // `notModified` is decided inside the protocol before this + // layer could re-judge it. Gating the cached body would + // therefore ship a filtered body under a validator that + // identifies the unfiltered one. + // + // That mismatch is not academic, because the two have + // different lifetimes. Within one boot the registered-service + // set is fixed (`Kernel.use()` throws once bootstrap has + // started, and no deregistration API exists), so the gate + // verdict is stable per process — but `Cache-Control: + // private, no-cache` means the client STORES the body and + // revalidates, and that stored body outlives the process. + // A redeploy that turns the optional service off does not + // change the document, so the ETag is unchanged, every + // revalidation answers 304, and the stale unfiltered body + // stands: the dead tile D10 exists to prevent, now cached + // indefinitely. Bypassing costs nothing to weigh against + // that — `getMetaItemCached` delegates to `getMetaItem`, + // so the server does identical work either way and only + // the 304's saved body bytes are given up. + // + // Compared on the NORMALIZED type, like every other + // exclusion in this condition (`/meta/dashboards/x` is + // the canonical plural spelling under Prime Directive + // #3, and an exclusion it could be spelled around would + // not be an exclusion). The `doc` / `book` literals + // that stood at the end of this condition had exactly + // that hole; commit 83a3b1f2e closed it. + const isDashboardType = metaType === 'dashboard'; + // ADR-0046 §6.7 — the two audience-gated types, excluded + // from the cache so {@link metaItemReadGate} judges them. + const isAudienceGatedType = metaType === 'book' || metaType === 'doc'; + // [#22877] A member-addressed read bypasses the cache + // too: the cached read takes no member and keys its + // validator on type and name, so it can only answer the + // canonical member. + const memberScoped = memberSelector.member !== undefined; + if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !memberScoped && !isAudienceGatedType) { + // [ADR-0106 D3] When a projection applies, the + // protocol is NOT allowed to judge the conditional + // request: `getMetaItemCached` hashes the UNFILTERED + // document, so a `304` decided there would pin this + // caller to a body no mask ever touched — the same + // validator-vs-served-body mismatch #5881 recorded + // for the dashboard gate. The comparison moves below, + // against the fingerprinted ETag, which is the one + // that identifies what we are actually sending. + // [#22250] A D4-exempt caller's posture carrying + // `runtime` is one: the definition is served whole, + // but the `sortability` beside it follows that + // caller's own field permission, which the + // protocol's validator does not hash either. + const maskApplies = maskPosture.kind !== 'passthrough' || maskPosture.runtime !== undefined; + // [#21476] Same move for a `view`: its body can carry + // the public-form intake reason, which derives from + // the posture and the bound object, and the + // protocol's validator hashes neither. With no reason + // the folded ETag is byte-identical, so a view's + // `304` answers exactly as before. + const intakeFolds = metaType === 'view'; + // [#22639] And for the two types this arm serves that + // the per-caller read gate judges — a list view (one + // refused whole, a container pruned) and an object + // (its own `listViews` pruned): the body varies per + // caller by what the gate withheld, which the + // protocol's validator does not hash. Nothing + // withheld folds nothing, so the `304` answers as + // before. + const audienceFolds = metaType === 'view' || metaType === 'object'; + const cacheRequest = { + ifNoneMatch: (maskApplies || intakeFolds || audienceFolds) ? undefined : (req.headers['if-none-match'] as string), + ifModifiedSince: req.headers['if-modified-since'] as string, + }; + + // Resolve the response locale up-front and fold it + // into the cache key. The body is translated below + // (`translateMetaItem`) *after* this validator runs, + // so without a locale-aware ETag a language switch + // would return a stale-locale 304 (issue #1319). + const cacheI18n = await this.resolveI18nService(environmentId, req); + const cacheLocale = this.extractLocale(req, cacheI18n); + + // [commit 2a29caa53] Typed request — `as any` retired. The + // cached read carries NO draft-visibility members + // on purpose: this branch is unreachable when a + // draft switch is ADMITTED (`previewDrafts` / + // `isDraftRead` true — the fork above bypasses the + // cache for both), and the implementation's + // `getMetaItemCached` signature declares neither. + // [#20320] The query PARAMETER still reaches here: + // a caller `mayReadPendingDrafts` does not admit + // sends `?state=draft` / `?preview=draft` and is + // answered this arm, the plain read's (#20338). + const cachedRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + cacheRequest, + ...(cacheLocale ? { locale: cacheLocale } : {}), + ...(environmentId ? { environmentId } : {}), + // [#9454] The cached door is the `view` arm, and + }; + const result = await p.getMetaItemCached(cachedRequest); + // [#23316] The automation engine's verdict on a + // FLOW, beside the document and never inside it: + // this branch is the default door of + // `GET /meta/flow/:name`, so without it a flow the + // engine refused was served here as its stored + // `status` alone. The protocol reads it live from + // its delegate read on every call; it is in no + // validator, so a read carrying it is never + // answered 304 (`getMetaItemCached` says why). + // Flow only — every other type's cached envelope + // is unchanged. + const cachedRegistration = metaType === 'flow' && result.registration !== undefined + ? { registration: result.registration } + : {}; + + if (result.notModified) { + res.status(304).send(); + return; + } + + // [ADR-0106 D1/D3] fetch → mask → send. The shared + // cache still stores ONE full schema per (type, + // name, locale, environment) — no caller dimension + // in the key — and what varies per caller is this + // projection plus the validator below. + let cachedDocument: any = result.data; + // [#22639] THE per-caller read gate, before the mask — + // the item chain's order (`createMetaItemAnswer`: gate, + // then mask). This arm serves `view` and `object`, the + // two types it judges here: a list view the caller + // does not hold is refused whole, a container and an + // object are served minus the list views withheld. + // The same judge every other door asks — ⛔ no + // decision of its own here. What it withheld is + // folded into the validator below. + let audienceFingerprint = ''; + if (audienceFolds) { + const verdict = await this.metaItemReadGate( + environmentId, req, p, metaType, req.params.name, [cachedDocument], + { arms: 'all', app: 'gate' }, + )(cachedDocument); + if (verdict.kind === 'refuse') { + verdict.send(res); + return; + } + audienceFingerprint = objectFieldVisibilityFingerprint( + metaReadGate.withheldListViewNames(cachedDocument, verdict.document), + ); + cachedDocument = verdict.document; + } + let visibilityFingerprint = ''; + if (maskPosture.kind === 'project') { + // [#21884] Related to the fetched document: its `objectOverride` params name other objects. + const related = await relateObjectSchemaMaskPosture(maskPosture, cachedDocument); + const masked = this.maskObjectDocument(res, related, req.params.name, cachedDocument); + if (!masked) return; + cachedDocument = masked.document; + visibilityFingerprint = masked.fingerprint; + } + // [#21476] The administrator's read names why an open + // public form is not offered on this posture. + let intakeFingerprint = ''; + if (intakeFolds) { + const warnings = await this.anonymousFormIntakeWarnings(environmentId, req, p, cachedDocument); + cachedDocument = stampAnonymousFormIntakeWarnings(cachedDocument, warnings); + intakeFingerprint = anonymousFormIntakeFingerprint(warnings); + } + // [#22250] The caller's runtime view of what is + // served — the document itself unless the caller is + // D4-exempt and their own field permission withholds + // some of its fields — which `sortability` derives + // from, and whose fingerprint the validator folds. + const runtimeView = await resolveObjectSchemaRuntimeView(cachedDocument, maskPosture); + + // [ADR-0106 D6 tier 2] Visibility undetermined → + // the body is unmasked, so it must not be stored or + // revalidated under a SHARED validator: a later 304 + // would hand this body to a caller whose projection + // did resolve. No ETag, no Last-Modified, no-store. + if (maskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + res.header('Vary', 'Accept-Language'); + res.json(await this.translateMetaEnvelope( + req, req.params.type, environmentId, + { type: metaType, name: req.params.name, ...cachedRegistration }, + cachedDocument, cacheI18n, runtimeView.document, + )); + return; + } + + // Set cache headers + if (result.etag) { + // [ADR-0106 D3] Fold the caller's field-visibility + // fingerprint into the shared validator. An + // unrestricted caller denies nothing → the + // fingerprint is empty → the ETag is byte-identical + // to the pre-ADR one. A cohort shares 304s; a + // permission change moves the fingerprint and + // self-invalidates the stale 304. [#22250] So does + // the runtime view's: a D4-exempt caller whose + // permission withholds a field is served a + // different `sortability`, never a 304 for another's. + // [#22639] …and so does what the read gate + // withheld: a caller who no longer holds a list + // view's capability is never answered a `304` + // for the body that carried it. + const value = foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), + runtimeView.fingerprint, + ), + intakeFingerprint, + ), + audienceFingerprint, + ); + const etagValue = result.etag.weak + ? `W/"${value}"` + : `"${value}"`; + res.header('ETag', etagValue); + if ((maskApplies || intakeFolds || audienceFolds) && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { + res.status(304).send(); + return; + } + } + if (result.lastModified) { + res.header('Last-Modified', new Date(result.lastModified).toUTCString()); + } + if (result.cacheControl) { + // `max-age` is a placeholder directive in the + // array; its real value is appended from the + // `maxAge` field. Strip the bare token before + // joining so the two never collide into the + // malformed `public, max-age, max-age=3600`. + const parts: string[] = result.cacheControl.directives + .filter((d: string) => d !== 'max-age'); + if (result.cacheControl.maxAge != null) { + parts.push(`max-age=${result.cacheControl.maxAge}`); + } + res.header('Cache-Control', parts.join(', ')); + } + + res.header('Vary', 'Accept-Language'); + // [#5563] `getMetaItemCached` hands back the metadata + // document with the envelope already stripped + // (`result.data`; it does `const item = result?.item` + // internally). This branch is the DEFAULT — `enableCache` + // defaults to `true` — so leaving it unwrapped made the + // one shape `packages/spec` declares for this route the + // one a default deployment could never obtain. Rebuild + // the declared envelope here, in the REST layer that + // owns the response contract. + // + // `type` is folded to the canonical singular exactly as + // `metadata-protocol` folds it (#4432), so `/meta/objects/x` + // and `/meta/object/x` cannot answer two different `type` + // values across a configuration switch. The cached read + // carries no `lock` — it is the fast published-value path + // and never consulted the lock resolver; a caller that + // needs the ADR-0008 OCC carriers reads the uncached path. + // [#22114] That includes `version`, the read's token. Its + // ETag above stays the CACHE validator and is not the token: + // the validator varies by locale (#1319), by the caller's + // field visibility (ADR-0106 D3) and by the served bytes + // (`getMetaItemCached`, #16525), none of which moves the + // stored row's version, and an item with no stored row has + // a validator but no version at all. + const cachedEnvelope = { + type: metaType, + name: req.params.name, + ...cachedRegistration, + }; + res.json(await this.translateMetaEnvelope( + req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, + runtimeView.document, + )); + } else { + // Non-cached version + // [#22128] The save door's reading of `?package=` + // ({@link metaItemPackageBinding}): the served + // `version` is resolved at the address that save + // writes, so `all` names no package here either. + const packageId = metaItemPackageBinding(req.query?.package); + // [commit 2a29caa53] Typed against the spec request shape — + // the `as any` this literal used to carry is + // retired now that the spec declares `state` and + // `previewDrafts`. + // No transport envelope: this door does not thread + // `environmentId` (the kernel was already resolved + // above), so the plain declared shape suffices. + const itemRequest: GetMetaItemRequest = { + type: req.params.type, + name: req.params.name, + packageId, + // [#20338] The ADMITTED switches declared above, + // never `req.query` re-read here. + ...(isDraftRead ? { state: 'draft' as const } : {}), + ...(previewDrafts ? { previewDrafts: true } : {}), + // [#22877] The member, read once above. + ...memberSelector, + }; + const envelope = await p.getMetaItem(itemRequest) as Record; + + // [#20408] Everything this read does to the envelope + // after the store read is ONE chain, + // `createMetaItemAnswer` in `./meta-item-read-gate.ts`, + // which the runtime dispatcher's `/meta` item read calls + // too. Its docblock carries each step's rule and their + // order — [#18066] absence judged BEFORE the gate (a name + // with nothing behind it can never become #8013's `403`), + // [#20156] THE per-caller read gate, the ADR-0046 doc + // locale collapse, the [ADR-0106 D1/D5(1)] mask under the + // posture resolved above (this uncached exit serves + // `?state=draft`, `?preview=draft`, `?package=` and every + // `enableCache: false` deployment, so a mask living only + // in the cached arm would be walked past by a query + // parameter), and the body: the translation and #10235's + // `sortability`. ⛔ An item step is added there, never + // here (`meta-list-projection-parity.test.ts` in + // `@objectstack/runtime` derives its census from this + // handler and fails on one added here). + // + // The gate's policy is this read's: every arm and the app + // PRUNED, to every caller, its authors included + // (read-to-display is per user) — save [#20290] the + // `?state=draft` branch, which serves a STORED version + // (the pending draft row) and so reads under the + // stored-version doors' policy + // ({@link STORED_VERSION_DOOR_POLICY}): Studio's designers + // merge this answer over the layered view and save it + // back, so whoever may save the app reads its draft + // whole, every other caller pruned per caller (ruling + // 5856774816), and no per-DEPLOYMENT gate. [#20338] "Every + // other caller" is every other caller who may READ + // drafts: one who may not never reaches this branch + // (`isDraftRead` is false for them). + const readPolicy: MetaReadGatePolicy = isDraftRead + ? this.STORED_VERSION_DOOR_POLICY + : { arms: 'all', app: 'gate' }; + const answer = await metaReadGate.createMetaItemAnswer( + this.metaItemAnswerSources(environmentId, req, p, readPolicy), + { metaType, name: req.params.name, policy: readPolicy, maskPosture }, + )(envelope); + if (answer.kind === 'refuse') { + // `absent` is {@link sendMetaItemAbsent}, byte for byte. + this.sendMetaReadRefusal(res, answer.refusal); + return; + } + if (answer.kind === 'mask-fault') { + sendFieldVisibilityFault(res, answer.object); + return; + } + // [ADR-0106 D6 tier 2] Visibility undetermined → the + // body is unmasked, so it must not be stored or + // revalidated under a shared validator. + if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); + res.header('Vary', 'Accept-Language'); + res.json(answer.envelope); + } + } catch (error: any) { + // [#18402] THE one absence answer, whichever arm + // produced it — the last half of #18066. + // + // #18066 gave this route a single absence EMITTER + // ({@link sendMetaItemAbsent}) and reached it from the + // two conditions that RETURN nothing. The conditions + // that THROW one were left on the classification door + // below, which renders the flat `{ error: '', + // code }` — so `body.error.code`, the accessor #8013 + // settled on and objectui#4252 reads, was `undefined` + // on exactly those. Which one a caller got was decided + // by two things it cannot see: + // + // - `metadata.enableCache` (default TRUE). The cached + // arm's `getMetaItemCached` THROWS + // `metadataItemNotFoundError` on a falsy `item`; the + // uncached arm resolves item-less and returns. One + // request, one missing name, two envelopes, chosen + // by a server setting — the #7035 failure class. + // - which protocol implementation is mounted. The + // in-repo `metadata-protocol` resolves item-less + // from `getMetaItem`, but a protocol that throws the + // miss instead reached the same flat door + // (pinned in `rest-meta-outage-vs-miss.test.ts`). + // + // Recognised by the ANSWER this repo's own + // classification door would have given — see + // {@link thrownAnswerIsBareNotFound}, which asks that + // door rather than re-reading the error, so this fork + // and the `handleRouteError` it forks away from cannot + // drift apart about what a caught value means. + // + // ⛔ NOT "the status is 404", and that narrowing was + // measured rather than assumed. `NO_DRAFT` is a 404 on + // THIS route — the Studio designer's `?state=draft` + // probe, pinned byte-for-byte two files over — and it + // says the item IS there and its draft is not. + // Answering it as absence would tell a designer the + // object does not exist, which is #5532's flattening + // reintroduced by the repair for a sibling of it. Same + // reasoning excludes a producer-declared code the + // ledger does not know: ADR-0112 keeps that spelling in + // `declaredCode`, and converting would delete it. + // + // ⭐ It STRENGTHENS the ADR-0045 §3 property rather + // than merely preserving it. The unpublished app and + // the service-gated one answer through the emitter, so + // an absence that kept the thrown dialect was a + // response pair that told them apart — by envelope + // shape, and by the producer's `Metadata item + // / not found` prose where the emitter says + // one fixed sentence. Four arms, one body now; the + // byte-identity is pinned in + // `meta-item-absent-404.test.ts` §2 and §5. + // + // ⛔ NOT a convergence of the flat dialect itself. The + // audience gate's `sendDeclaredFault` 401/403 beside + // this, and the door in `error-response.ts`, still + // answer flat: that position is the live ratchet + // #9559 owns repo-wide (`check:route-envelope`), and + // converting two of its four emissions here would mint + // a new divergence — the same refusal answering two + // shapes depending on which ROUTE served it. + if (thrownAnswerIsBareNotFound(error)) { + sendMetaItemAbsent(res); + return; + } + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Get specific metadata item', + tags: ['metadata'], + }, + }); + } +} + +// [#23050 slice 13b] Moved here from `rest-server.ts`, verbatim, with the body above: +// its one caller is the reference sweep's catch, and "this door" and "this route" in +// its docblock are that sweep. +/** + * [#15685] The `/meta/:type/:name/references` door's PROTOCOL-RAISED refusal, + * re-dressed in the ADR-0112 NESTED envelope that door's other refusal exit + * already publishes. Answers `undefined` for everything else, so the caller + * keeps `handleRouteError` for the rest. + * + * ## The defect this closes + * + * The door can refuse in two ways and, measured on one boot, the two answers + * agreed on neither the envelope nor the message: + * + * ``` + * A protocol-raised, unanswerable TARGET type (#9327) + * 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} + * B the resolved kernel has no `findReferencesToMeta` at all (#9326) + * 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} + * ``` + * + * Two facts are lost on A, and both of them are the operator's: + * + * 1. **The PRESCRIPTION.** A's message is not decoration — `findReferencesToMeta` + * says so in as many words ("The message is prescriptive per ADR-0110 D3: + * it names the answerable question"). It tells the operator what to ask + * INSTEAD: `Ask the owning object instead: GET /api/v1/meta/object//references`. + * That sentence is what keeps "the question was never asked" from being + * read as "nothing depends on this item" — the whole of ADR-0110 D3 + * (#8896), in front of an operator whose next click is a delete, because + * the admin "Used by" panel renders its empty case as "Nothing in the + * metadata graph points at this item. Safe to delete." On the wire the + * sentence was replaced by "Internal server error". + * 2. **The `code`'s POSITION.** `body.error.code` reads on B and `undefined` + * on A. This door's own comment on the B branch warns against precisely + * that dialect ("never the bare-string or sibling-`code` dialects, which + * make `body.error.code` read `undefined`") — so the route was violating + * its own written rule at its other exit, reached by a different path. + * + * ## Why the repair is HERE and not at the relay + * + * A reaches the wire through `handleRouteError` → {@link declaredServerFaultAnswer}, + * which keeps `status` and `code` and replaces the prose with + * `INTERNAL_ERROR_MESSAGE`. That arm is correct and argued for a server FAULT + * (#11718, #5582) — a fault message may carry driver internals, and withholding + * it is the point. What it cannot see is that a producer-declared 5xx might be + * a deliberate REFUSAL whose message is authored FOR the caller. Teaching it + * that distinction would change platform-wide behaviour for every + * producer-declared 5xx at every door; that is a maintainer's decision and is + * handed back as its own finding, not taken here. This arm is the bounded half: + * ONE door, re-dressing ONE refusal in the dialect it already publishes. + * + * ⛔ It is therefore NOT a general "5xx prose is relayed now" rule, and the + * three conditions below are what keep it from becoming one. + * + * ## The three conditions, and why each is exactly this narrow + * + * - **`501`**, read through {@link declaredHttpStatus} so both declaration + * spellings (`status` / `statusCode`, #7525) reach the same verdict rather + * than through a fourth local opinion about which field declares a status. + * - **`code === 'NOT_IMPLEMENTED'`**, the literal this route already publishes + * on its B exit. Matching the LITERAL rather than "any declared code" is + * also how the #9232 vocabulary narrowing is honoured by CONSTRUCTION: an + * unregistered producer spelling can never reach this exit, so no second + * copy of `thrownCodeFields`' demotion rule is needed and no new door ships + * an un-narrowed code. ⚠️ The cost is stated rather than hidden: a future + * SECOND refusal code on this route would fall back to the flat fault answer + * until whoever adds it comes here. That is a visible, one-line extension, + * not a silent gap. + * - **A DECLARED refusal.** [#16146] This was "a non-empty message", and it + * was this arm's own opinion about which producer-declared 5xx keeps its + * prose — the very question the relay could not answer when this was + * written. It can now: the director seat ruled the distinction a + * producer-side declaration on the published ADR-0112 envelope (decision + * batch #58, 2026-09-06, option C) and the producer sets it + * (`findReferencesToMeta`, `metadata-protocol`). So the condition is + * {@link boundedDeclaredRefusalMessage} — the shared relay's own answer, + * bound and all — and this route holds no refusal/fault opinion of its own + * any more. + * + * ## [#16146] What was RETIRED here, and the one half that could not be + * + * The ruling says to retire this route-local patch once the relay handles + * `/references`, and its PROSE half is retired exactly as ruled: the sentence + * now reaches the wire because the relay keeps it at EVERY door, the bound is + * the shared one rather than this arm's unbounded pass-through, and deleting + * the call below would change no message on this route. + * + * ⚠️ What deleting it WOULD change is the ENVELOPE, and that is a different + * decision. This function also re-dresses the answer into the NESTED ADR-0112 + * envelope this door's B exit publishes; the relay is flat + * (`{ error, code }` through `handleRouteError`), so removing this arm would + * put `body.error.code` back to `undefined` on the A exit and re-open the + * SECOND half of the defect #15685 measured and pinned positionally in + * `rest-server-meta-references-refusal-envelope.test.ts`. Envelope POSITION is + * owned by the `check:route-envelope` ratchet and is explicitly a separate + * line from vocabulary (ADR-0112's #9232 amendment says so in as many words), + * so it is not folded into a prose ruling. What remains here is therefore a + * pure position adapter over the shared answer — ⛔ not a second withhold arm, + * and ⛔ not a place to add a refusal rule. + * + * ⛔ And it does not re-derive `REFERENCE_SITES.unanswerableTargetTypes` to + * decide whether the target was answerable. That set, its canonical-type fold + * and its refusal all belong to `findReferencesToMeta`; a second copy at the + * transport is the tolerant-consumer direction, and it would drift the moment + * the set changed. The route reads what the protocol DECLARED, which is what a + * transport is for. + */ +function notImplementedRefusalAnswer( + error: any, +): { status: number; body: { error: { code: string; message: string } } } | undefined { + if (declaredHttpStatus(error) !== 501) return undefined; + if (error?.code !== 'NOT_IMPLEMENTED') return undefined; + const message = boundedDeclaredRefusalMessage(error); + if (message === undefined) return undefined; + return { status: 501, body: { error: { code: 'NOT_IMPLEMENTED', message } } }; +} + +// [#23050 slice 13b] Moved here from `rest-server.ts`, verbatim, with the body above: +// its one caller is the item read's intake-reason fold. +/** [#21476] Those reasons' ETag dimension; empty when there is none (the ADR-0106 D3 fold). */ +function anonymousFormIntakeFingerprint(warnings: ReadonlyArray<{ path: string; message: string }>): string { + return objectFieldVisibilityFingerprint(warnings.map((w) => JSON.stringify([w.path, w.message]))); +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 43a35f01634..8f0876b9374 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -55,14 +55,14 @@ import { import { ObjectSchemaMaskEvaluationError, applyObjectSchemaMask, - foldVisibilityFingerprintIntoEtag, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, - normalizeIfNoneMatch, relateObjectSchemaMaskPosture, resolveObjectSchemaMaskPosture, - resolveObjectSchemaRuntimeView, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, + // `foldVisibilityFingerprintIntoEtag`, `normalizeIfNoneMatch` and + // `resolveObjectSchemaRuntimeView`, read only by the item read's conditional + // answer, moved with that body to `meta-item-read-routes.ts` (#23050 slice 13b). type ObjectSchemaMaskPosture, // [#12702 · ADR-0131 D6] Which callers a `/meta` item write door admits: // `manage_metadata` (or `isSystem`). One predicate for every `/meta` item @@ -88,9 +88,10 @@ import { anonymousFormIntakeUnavailableMessage, anonymousFormObjectName, anonymousFormSharingPath, - // [#21476] The ADR-0106 fingerprint the admin read folds the reason into - // — and [#22639] the list views the read gate withheld. - objectFieldVisibilityFingerprint, + // [#21476] `objectFieldVisibilityFingerprint`, the ADR-0106 fingerprint the admin + // read folds the reason into — and [#22639] the list views the read gate + // withheld — read only by the item read and its intake-reason helper, moved with + // them to `meta-item-read-routes.ts` (#23050 slice 13b). } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -140,7 +141,8 @@ import { isAppPackageIdKey } from '@objectstack/spec/api'; import type { GetMetaItemsRequest, GetMetaItemRequest, - GetMetaItemCachedRequest, + // `GetMetaItemCachedRequest`, the item read's cached-path request, moved with that + // body to `meta-item-read-routes.ts` (#23050 slice 13b). GetMetaItemLayeredRequest, PublishMetaItemRequest, AuditMetaItemRequest, @@ -206,7 +208,8 @@ export { filterAppForUser } from './meta-item-read-gate.js'; // [#23072] What the by-name `/meta` doors read beside the query decision both // transports ask (`metaItemQueryRefusal`) — see the re-export above the sets. import { - META_ITEM_LAYERS_PARAMS, + // `META_ITEM_LAYERS_PARAMS` is read only by the `/layers` door, which moved to + // `meta-item-read-routes.ts` (#23050 slice 13b); the re-export below still names it. META_ITEM_MEMBER_PARAM, META_ITEM_PUBLISHED_PARAMS, META_ITEM_SAVE_PARAMS, @@ -319,6 +322,9 @@ import { type MetaRouteScope, type TransportScopedMetaRequest, } from './meta-collection-routes.js'; +// [#23050 slice 13b] The `/meta` per-item reads (`/references`, `/layers`, the book tree +// and the item read) moved to their own module — see its header. +import { registerMetaItemReadRoutes, type MetaItemReadRouteHost } from './meta-item-read-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -352,14 +358,16 @@ import { // `classifiedRefusalAnswer`, `nestedRefusalEnvelope` and `sandboxFaultAnswer`, read only // by the two security families' refusal and fault arms, moved with those bodies to // `security-routes.ts` (#23050 slice 6). - boundedDeclaredRefusalMessage, - declaredHttpStatus, + // `boundedDeclaredRefusalMessage` and `declaredHttpStatus`, read only by the + // `/references` door's declared-refusal re-dresser, moved with it to + // `meta-item-read-routes.ts` (#23050 slice 13b). // `sendThrownError`, the OpenAPI document's and viewer's catch-all answer, moved with // `registerOpenApiEndpoints`' body to `discovery-routes.ts` (#23050 slice 7). sendDeclaredFault, sendFieldVisibilityFault, handleRouteError, - thrownAnswerIsBareNotFound, + // `thrownAnswerIsBareNotFound`, read only by the item read's catch, moved with that body + // to `meta-item-read-routes.ts` (#23050 slice 13b). // `logUnexpectedRouteError` and `applyDroppedFieldsHeader`, read only by the CRUD doors' catches // and their two write answers, moved with `registerCrudEndpoints`' body to `crud-routes.ts` // (#23050 slice 12). @@ -1557,120 +1565,10 @@ export async function wiredEngineOrLoud( } } -/** - * [#15685] The `/meta/:type/:name/references` door's PROTOCOL-RAISED refusal, - * re-dressed in the ADR-0112 NESTED envelope that door's other refusal exit - * already publishes. Answers `undefined` for everything else, so the caller - * keeps `handleRouteError` for the rest. - * - * ## The defect this closes - * - * The door can refuse in two ways and, measured on one boot, the two answers - * agreed on neither the envelope nor the message: - * - * ``` - * A protocol-raised, unanswerable TARGET type (#9327) - * 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} - * B the resolved kernel has no `findReferencesToMeta` at all (#9326) - * 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} - * ``` - * - * Two facts are lost on A, and both of them are the operator's: - * - * 1. **The PRESCRIPTION.** A's message is not decoration — `findReferencesToMeta` - * says so in as many words ("The message is prescriptive per ADR-0110 D3: - * it names the answerable question"). It tells the operator what to ask - * INSTEAD: `Ask the owning object instead: GET /api/v1/meta/object//references`. - * That sentence is what keeps "the question was never asked" from being - * read as "nothing depends on this item" — the whole of ADR-0110 D3 - * (#8896), in front of an operator whose next click is a delete, because - * the admin "Used by" panel renders its empty case as "Nothing in the - * metadata graph points at this item. Safe to delete." On the wire the - * sentence was replaced by "Internal server error". - * 2. **The `code`'s POSITION.** `body.error.code` reads on B and `undefined` - * on A. This door's own comment on the B branch warns against precisely - * that dialect ("never the bare-string or sibling-`code` dialects, which - * make `body.error.code` read `undefined`") — so the route was violating - * its own written rule at its other exit, reached by a different path. - * - * ## Why the repair is HERE and not at the relay - * - * A reaches the wire through `handleRouteError` → {@link declaredServerFaultAnswer}, - * which keeps `status` and `code` and replaces the prose with - * `INTERNAL_ERROR_MESSAGE`. That arm is correct and argued for a server FAULT - * (#11718, #5582) — a fault message may carry driver internals, and withholding - * it is the point. What it cannot see is that a producer-declared 5xx might be - * a deliberate REFUSAL whose message is authored FOR the caller. Teaching it - * that distinction would change platform-wide behaviour for every - * producer-declared 5xx at every door; that is a maintainer's decision and is - * handed back as its own finding, not taken here. This arm is the bounded half: - * ONE door, re-dressing ONE refusal in the dialect it already publishes. - * - * ⛔ It is therefore NOT a general "5xx prose is relayed now" rule, and the - * three conditions below are what keep it from becoming one. - * - * ## The three conditions, and why each is exactly this narrow - * - * - **`501`**, read through {@link declaredHttpStatus} so both declaration - * spellings (`status` / `statusCode`, #7525) reach the same verdict rather - * than through a fourth local opinion about which field declares a status. - * - **`code === 'NOT_IMPLEMENTED'`**, the literal this route already publishes - * on its B exit. Matching the LITERAL rather than "any declared code" is - * also how the #9232 vocabulary narrowing is honoured by CONSTRUCTION: an - * unregistered producer spelling can never reach this exit, so no second - * copy of `thrownCodeFields`' demotion rule is needed and no new door ships - * an un-narrowed code. ⚠️ The cost is stated rather than hidden: a future - * SECOND refusal code on this route would fall back to the flat fault answer - * until whoever adds it comes here. That is a visible, one-line extension, - * not a silent gap. - * - **A DECLARED refusal.** [#16146] This was "a non-empty message", and it - * was this arm's own opinion about which producer-declared 5xx keeps its - * prose — the very question the relay could not answer when this was - * written. It can now: the director seat ruled the distinction a - * producer-side declaration on the published ADR-0112 envelope (decision - * batch #58, 2026-09-06, option C) and the producer sets it - * (`findReferencesToMeta`, `metadata-protocol`). So the condition is - * {@link boundedDeclaredRefusalMessage} — the shared relay's own answer, - * bound and all — and this route holds no refusal/fault opinion of its own - * any more. - * - * ## [#16146] What was RETIRED here, and the one half that could not be - * - * The ruling says to retire this route-local patch once the relay handles - * `/references`, and its PROSE half is retired exactly as ruled: the sentence - * now reaches the wire because the relay keeps it at EVERY door, the bound is - * the shared one rather than this arm's unbounded pass-through, and deleting - * the call below would change no message on this route. - * - * ⚠️ What deleting it WOULD change is the ENVELOPE, and that is a different - * decision. This function also re-dresses the answer into the NESTED ADR-0112 - * envelope this door's B exit publishes; the relay is flat - * (`{ error, code }` through `handleRouteError`), so removing this arm would - * put `body.error.code` back to `undefined` on the A exit and re-open the - * SECOND half of the defect #15685 measured and pinned positionally in - * `rest-server-meta-references-refusal-envelope.test.ts`. Envelope POSITION is - * owned by the `check:route-envelope` ratchet and is explicitly a separate - * line from vocabulary (ADR-0112's #9232 amendment says so in as many words), - * so it is not folded into a prose ruling. What remains here is therefore a - * pure position adapter over the shared answer — ⛔ not a second withhold arm, - * and ⛔ not a place to add a refusal rule. - * - * ⛔ And it does not re-derive `REFERENCE_SITES.unanswerableTargetTypes` to - * decide whether the target was answerable. That set, its canonical-type fold - * and its refusal all belong to `findReferencesToMeta`; a second copy at the - * transport is the tolerant-consumer direction, and it would drift the moment - * the set changed. The route reads what the protocol DECLARED, which is what a - * transport is for. - */ -function notImplementedRefusalAnswer( - error: any, -): { status: number; body: { error: { code: string; message: string } } } | undefined { - if (declaredHttpStatus(error) !== 501) return undefined; - if (error?.code !== 'NOT_IMPLEMENTED') return undefined; - const message = boundedDeclaredRefusalMessage(error); - if (message === undefined) return undefined; - return { status: 501, body: { error: { code: 'NOT_IMPLEMENTED', message } } }; -} +// [#15685] `notImplementedRefusalAnswer`, the `/meta/:type/:name/references` door's re-dresser of +// a protocol-raised, declared `501 NOT_IMPLEMENTED` refusal into the nested envelope that door +// publishes, moved with its one caller to `meta-item-read-routes.ts` (#23050 slice 13b). Its +// docblock moved with it: the defect it closes and its three conditions are there. /** * [#18066] THE one absence answer `GET /meta/:type/:name` gives — a single @@ -1717,7 +1615,7 @@ function notImplementedRefusalAnswer( * app's answer does not is the enumeration signal all over again. The nested * `error.code` accessor is the same one objectui#4252 reads on both. */ -function sendMetaItemAbsent(res: any): void { +export function sendMetaItemAbsent(res: any): void { res.status(404).json({ error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' }, }); @@ -1955,7 +1853,7 @@ type MetaReadVerdict = * a derived view warning already goes (`stampRenameWarning`). A declared read * decoration, so a GET then PUT round trip never stores it. */ -function stampAnonymousFormIntakeWarnings( +export function stampAnonymousFormIntakeWarnings( document: any, warnings: ReadonlyArray<{ path: string; message: string }>, ): any { @@ -1966,10 +1864,8 @@ function stampAnonymousFormIntakeWarnings( return { ...document, _diagnostics: diagnostics }; } -/** [#21476] Those reasons' ETag dimension; empty when there is none (the ADR-0106 D3 fold). */ -function anonymousFormIntakeFingerprint(warnings: ReadonlyArray<{ path: string; message: string }>): string { - return objectFieldVisibilityFingerprint(warnings.map((w) => JSON.stringify([w.path, w.message]))); -} +// [#21476] `anonymousFormIntakeFingerprint`, those reasons' ETag dimension, moved with its one +// caller (the item read's intake-reason fold) to `meta-item-read-routes.ts` (#23050 slice 13b). /** * RestServer @@ -5334,896 +5230,34 @@ export class RestServer { }; registerMetaCollectionRoutes.call(collectionHost, scope); - // GET /meta/:type/:name - Get specific item - // - // [#15542 / #15854] The first four members of the per-item face. The - // rest of it — `PUT`, `DELETE` and the history family — is registered - // below this block's closing brace and goes through - // {@link registerPerItemRoute}, which carries the SAME switch. ⛔ The - // brace is not the radius: read the pin table in - // `rest-config-mount-table.pin.test.ts` for what `item` gates. - if (metadata.endpoints.item !== false) { - // Phase 3a-references: /meta/:type/:name/references must be - // registered BEFORE /meta/:type/:name so the more-specific - // path wins under any first-match router strategy. - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/:type/:name/references`, - handler: async (req: any, res: any) => { - try { - // [#23072] Recognition first, over the measured closed - // set — empty: the sweep reads no query parameter. - if (refuseUnknownQueryParams(req, res, META_ITEM_REFERENCES_PARAMS)) return; - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).findReferencesToMeta !== 'function') { - // [#9326 / ADR-0110 D3] A MISS and a FAULT are - // different facts, and this branch is the second - // one: the resolved protocol cannot compute the - // reference graph AT ALL, so the question was never - // asked. Answering `{ references: [] }` reported it - // as the first — "nothing depends on this item" — - // and the admin "Used by" panel renders that empty - // case as "Nothing in the metadata graph points at - // this item. Safe to delete.", shown to an operator - // about to delete something. - // - // Refusing HERE rather than asserting at assembly is - // deliberate. `findReferencesToMeta` is not a member - // of `RestProtocol` (= `DataProtocol & - // MetadataProtocol`) — it is an ADR-0076 D9 - // server-only extension, which is why it is reached - // through a runtime cast at all. So a host that - // implements the DECLARED contract exactly is a - // CONFORMING deployment that lands here with no type - // error, and a boot-time assertion would promote an - // undeclared optional extension into a required one - // — a `packages/spec` contract decision, not a - // route one. What the route owes is that an - // unanswerable question comes back unanswered. - // - // Envelope per #7035: the ADR-0112 NESTED - // `{ error: { code, message } }` that the sibling - // `/meta` 501 refusals converged on — never the - // bare-string or sibling-`code` dialects, which make - // `body.error.code` read `undefined`. - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'protocol.findReferencesToMeta() is not available in this kernel', - }, - }); - return; - } - // [#13753 · ADR-0131 D6] The sweep reads the sources - // environment → code, the world the `/meta` doors serve: - // the per-organization overlay axis is retired, so no - // organization is stated. A legacy organization-scoped - // source row is served by no door until ADR-0131 C7 - // promotes it, and that ceremony re-judges what it - // carries; it is not swept here. - // [#15685] The protocol's OWN refusal is re-answered in - // the nested envelope the branch above already uses — - // see {@link notImplementedRefusalAnswer} for why the - // repair is here and how narrow it is. The catch is - // scoped to the protocol call ALONE, so what this arm - // can re-dress is mechanically the set of things - // `findReferencesToMeta` raised: neither - // `resolveProtocol` nor the `resolveExecCtx` seam above - // can reach it, whatever they declare. - let result: unknown; - try { - result = await (p as any).findReferencesToMeta({ - type: req.params.type, - name: req.params.name, - ...(environmentId ? { environmentId } : {}), - }); - } catch (raised: any) { - const refusal = notImplementedRefusalAnswer(raised); - // Anything else is the outage it always was — the - // 503 `getMetaItems` raises for a `sys_metadata` - // failure (#8896) still propagates to the terminal - // below, message-withheld and logged, unchanged. - if (refusal === undefined) throw raised; - res.status(refusal.status).json(refusal.body); - return; - } - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'List metadata items that reference this item', - tags: ['metadata'], - }, - }); - - // [#5882] GET /meta/:type/:name/layers — the three-layer diagnostic - // projection as its OWN resource. Registered BEFORE - // /meta/:type/:name for the same first-match reason as - // /references above. [commit 7986d973f] It also used to have to precede the - // compound `/:type/:section/:name`, which would otherwise capture - // this path with section=, name="layers"; that catch-all is - // retired, so only the /references-style reason remains. - // - // This path exists because the projection used to be reachable only - // as `GET /meta/:type/:name?layers=true` — the same route answering - // a SECOND, undeclared body shape depending on a query flag, while - // `packages/spec` declared one `responseSchema` for it. The ruled - // fix (maintainer, 2026-08-06) was one path per response shape, - // deliberately NOT teaching the route declaration to express - // "two shapes chosen by a flag": that would add a primitive every - // future tool has to understand, and conditional response selection - // is exactly where codegen and AI-written clients go wrong. - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/:type/:name/layers`, - handler: async (req: any, res: any) => { - try { - // [#23072] Recognition first, over the measured closed - // set; `serveMetaItemLayered` keeps the multiplicity - // rule it shares with the deprecated `?layers=` spelling. - if (refuseUnknownQueryParams(req, res, META_ITEM_LAYERS_PARAMS)) return; - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - // [ADR-0106 D2/D5] The dedicated path is its own - // schema-serving outlet — it resolves the caller's - // field-visibility posture exactly like the plain meta - // read does, with the NORMALIZED type (#3984 / commit 83a3b1f2e). - const layeredMetaType = RestServer.metaTypeSingular(req.params.type); - let maskPosture: ObjectSchemaMaskPosture; - try { - maskPosture = await (await this.resolveObjectMasker(environmentId, req, layeredMetaType))(req.params.name); - } catch (maskError: any) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - sendFieldVisibilityFault(res, req.params.name); - return; - } - throw maskError; - } - if (typeof (p as any).getMetaItemLayered !== 'function') { - // A dedicated path cannot fall through to the plain - // read the way the `?layers=` flag did — answering - // the merged `{ type, name, item }` envelope here - // would be answering a different resource with a - // shape this path never declares. - res.status(501).json({ - error: 'Layered metadata view not supported by protocol implementation', - code: 'NOT_IMPLEMENTED', - }); - return; - } - await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Get a metadata item as its three layers (code / overlay / effective)', - tags: ['metadata'], - }, - }); - - // ADR-0046 §6 — GET /meta/book/:name/tree - // Resolve a book spine against the docs that exist *now* into a - // rendered tree (membership is DERIVED, never stored — §6.2.1). An - // unknown name is treated as a package id and resolved against the - // implicit per-package book (§6.4). Anonymous requests only see a - // book whose `audience` is `public` (§6.7 read-layer gating). - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/book/:name/tree`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const prot = await this.resolveProtocol(environmentId, req); - // [#6877] One package scopes the book lookup. [#23214] The - // list lives beside the shared decision, as the list door's. - if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS['book-tree'])) return; - // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. - const packageId = metaItemPackageBinding(req.query?.package); - // [#20408] The route's whole answer is - // `createMetaBookTreeAnswer` in `./meta-item-read-gate.ts` - // — the book and doc reads, THE `DocsAudience` (#19790: one - // resolution, four doors), the §6.7 gate on the book's own - // audience, the doc locale collapse and the tree narrowed - // per caller — which the runtime dispatcher serves this - // route through too. ⛔ A step is added there, never here. - const answer = await metaReadGate.createMetaBookTreeAnswer( - { - ...this.metaReadAudienceSources(environmentId, req), - listTreeInput: (type, scopedPackageId) => { - const request: TransportScopedMetaRequest = { - type, - ...(scopedPackageId ? { packageId: scopedPackageId } : {}), - ...(environmentId ? { environmentId } : {}), - }; - return prot.getMetaItems(request); - }, - requestLocale: (i18n) => this.extractLocale(req, i18n), - }, - { name: req.params.name, packageId }, - ); - if (!answer.ok) { - const { code, message, status } = answer.refusal; - sendDeclaredFault(res, { code, message, status }); - return; - } - res.json(answer.tree); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Resolve a documentation book spine into its rendered tree (ADR-0046 §6)', - tags: ['metadata'], - }, - }); - - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/:type/:name`, - handler: async (req: any, res: any) => { - try { - // [#6877] Declared at the top for the same reason #3984 - // normalizes `:type` here: this handler reads its query - // parameters from four different branches hundreds of - // lines apart (`?layers=`, `?state=`, `?preview=`, - // `?package=`), and a per-branch gate is one a new branch - // inherits by accident rather than by construction. - // [#22877] …and RECOGNITION first, over the route's - // measured closed set (AGENTS.md route rule 5), which - // the bundle member selector joined. - // [#23072] All three of the route's query rules — - // recognition, arity and a member on the layered view — - // are ONE decision, `metaItemQueryRefusal` in - // `./meta-item-read-gate.ts`, which the runtime - // dispatcher's item read asks too (ruling 6138588561). - // The first two are answered here, in the nested body - // their gates always wrote; the third where the layered - // view is chosen, below. ⛔ A rule is added there, never - // here. - const queryRefusal = metaReadGate.metaItemQueryRefusal('item', req.query); - if (queryRefusal && queryRefusal.rule !== 'layered-member') { - res.status(queryRefusal.status).json({ - error: { code: queryRefusal.code, message: queryRefusal.message }, - }); - return; - } - const memberSelector = metaMemberSelector(req.query); - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - - // [#3984 / commit 83a3b1f2e] Normalize the `:type` segment ONCE, - // here at the top, and let every gate below read THIS - // value. The route serves both spellings and Prime - // Directive #3 makes the plural one canonical - // (`/meta/books/:name`), so any gate comparing the raw - // param is a gate the canonical spelling walks past. - // - // #3984 ruled this shape for exactly that reason ("每个 - // handler 顶部归一一次,后续所有闸门都用归一后的值"), and - // commit 83a3b1f2e is why the ruling is written into the code - // rather than trusted to memory: eight days after - // #3984 landed, the cache-branch condition below still - // excluded `doc`/`book` by LITERAL comparison, so - // `GET /meta/books/:name` took the cached branch and - // the §6.7 audience gate — which lives in the uncached - // branch — never ran at all. Measured on the real - // server, one `{ permissionSet }`-gated book, one - // signed-in caller holding no set: - // - // singular "book" :: cachedCalls=0 status=[403] - // plural "books" :: cachedCalls=1 status=[] ← full body served - // - // A new per-type gate added below inherits the - // normalization by default now; there is no raw param - // in scope for it to compare against by accident. - const metaType = RestServer.metaTypeSingular(req.params.type); - - // [ADR-0106 D2/D5] Resolve the caller's field-visibility - // posture ONCE, here, before any fetch — every exit - // below (layered, cached, uncached) projects through - // THIS value. Resolving per-branch is how an outlet gets - // forgotten; resolving before the fetch is what makes - // D3's `fetch → mask → send` ordering structural rather - // than a convention. - let maskPosture: ObjectSchemaMaskPosture; - try { - maskPosture = await (await this.resolveObjectMasker(environmentId, req, metaType))(req.params.name); - } catch (maskError: any) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - sendFieldVisibilityFault(res, req.params.name); - return; - } - throw maskError; - } - - // Phase 3a-layered-get: opt-in 3-state view when client - // asks for `?layers=true` (or any non-empty value). - // Skips the cache path entirely — layered view is a - // diagnostic endpoint, not on the hot read path. - // - // [#5563 → #5882] DEPRECATED SPELLING. This flag makes one - // route answer a SECOND resource representation — three - // layers side by side (`code` / `overlay` / `effective`), - // where `effective` is what the plain read returns — while - // the route declares a single `responseSchema`. #5563 - // converged the ordinary read and left this half open - // because collapsing the layers into - // `GetMetaItemResponseSchema`'s single `item` would delete - // the diagnostic outright. - // - // #5882 closed it the other way (maintainer ruling, 2026-08-06): - // the projection is now its own path, - // `GET /meta/:type/:name/layers`, declared by - // `GetMetaItemLayeredResponseSchema`. One path, one shape. - // - // This branch stays for a deprecation window so existing - // callers (Studio's metadata editor) are not broken by the - // move. It answers the IDENTICAL body — same helper, not a - // copy — and advertises the successor in the response - // headers, so a client can discover the migration without - // reading the changelog. Delete this branch (and the - // headers with it) once the callers have moved. - // - // [#20478] The flag's parse (`wantsMetaItemLayers`) and its - // headers (`metaItemLayersDeprecationHeaders`: RFC 9745 - // `Deprecation` + RFC 8288 `Link` to the successor) are the - // ones the runtime dispatcher's item read asks too, so the - // deprecated spelling is one answer on both transports. - // - // [#20508] The `Link` names the path THIS request arrived - // on (`IHttpRequest.path`), read the way the dispatcher - // reads its request URL (`requestedItemPath`, runtime - // `domains/meta.ts`): parsed as a URL path, without its - // trailing slash. ⛔ Never `metaPath` — on the - // environment-scoped mount that is the route TEMPLATE, and - // the successor read `/environments/:environmentId/…/layers`, - // a path no client can request. The parse is what keeps the - // path a valid URI reference: the Hono adapter hands over a - // `decodeURI`'d path (`lead%20all` arrives as `lead all`), - // and the parse percent-encodes it again. A request with no - // path names no successor: `Deprecation` alone, as the - // helper prescribes for a transport that cannot say where - // it serves the item. - const wantLayered = metaReadGate.wantsMetaItemLayers(req.query); - // [#22877] The layered view reads no member, so a member - // named on it is refused rather than answered with the - // canonical member's layers. [#23072] The decision's - // third rule (`layered-member`), the only one that can - // still be pending here; thrown to `handleRouteError` as - // it always was. - if (queryRefusal) { - throw Object.assign(new Error(queryRefusal.message), { - code: queryRefusal.code, - status: queryRefusal.status, - }); - } - if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { - const requestPath: unknown = req.path; - const deprecation = metaReadGate.metaItemLayersDeprecationHeaders( - typeof requestPath === 'string' && requestPath.startsWith('/') - ? new URL(`http://rest-server.invalid${requestPath}`).pathname.replace(/\/+$/, '') || undefined - : undefined, - ); - for (const [header, value] of Object.entries(deprecation)) res.header(header, value); - await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); - return; - } - - // Check if cached version is available. - // For `app` metadata we skip the cache path so the - // per-user RBAC filter below can apply without - // corrupting shared ETags across admin vs member - // viewers of the same app schema. Drafts also - // bypass cache: the cache is keyed on the - // published checksum and drafts are out-of-band. - const isAppType = metaType === 'app'; - // [#20338] The caller, resolved ABOVE the two draft - // switches because each is admitted per caller. - // Memoised per request — not a new seam. - const readCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - // [#20338] Both draft switches are declared WITH their - // admission ({@link mayReadPendingDrafts}). A caller who - // may not read drafts is answered this read as if - // neither switch were present — the published item, - // pruned as the plain read prunes it, or its absence — - // and no later branch re-reads `?state=` past the gate - // (the uncached arm used to parse it a second time). - const isDraftRead = typeof req.query?.state === 'string' - && req.query.state.toLowerCase() === 'draft' - && mayReadPendingDrafts(readCtx); - // ADR-0033/0037 — `?preview=draft` overlays a pending - // draft on the active item (draft wins, falls back to - // active). Must also bypass the cache: ETags are keyed - // on the published checksum, so a cached 304 would pin - // the preview to the stale published world. - const previewDrafts = typeof req.query?.preview === 'string' - && req.query.preview.toLowerCase() === 'draft' - && mayReadPendingDrafts(readCtx); - // ADR-0048 — a `?package=` read is package-scoped - // (prefer-local). The cached path keys ETags on - // type+name only and does NOT thread `packageId` into - // `getMetaItemCached`, so two installed packages shipping - // the same type/name would share one cache entry and the - // scope hint would be silently dropped. Bypass the cache - // when a package scope is requested so the disambiguating - // `getMetaItem(type, name, packageId)` path runs. - const packageScoped = typeof req.query?.package === 'string' - && req.query.package.length > 0; - // `doc` and `book` bypass the shared cache: their §6.7 - // audience gate is per-caller, and a shared ETag would - // leak gated content across viewers. - // - // [commit 83a3b1f2e] That sentence was already here while the - // exclusion beneath it compared the RAW param against - // the literals `'doc'` / `'book'`, so the canonical - // plural spelling took the cached branch and shipped - // the gated body. The exclusion is not incidental - // tidying — it is the stated security invariant above, - // and it now reads the normalized `metaType`. - // - // The bypass exists only to make the per-caller gate in - // the uncached branch reachable ({@link metaItemReadGate}, - // whose `book` / `doc` arm is the §6.7 gate). [#20156] - // A type that gate judges and this exclusion misses is - // served ungated from the cache: the census in - // `meta-alternate-door-read-gates.test.ts` drives this - // read with a cached protocol method present, so that - // miss reddens its plain-read row. - // - // [#5881] `dashboard` bypasses it too, and the reason is - // NOT the one above — worth writing down, because the - // obvious reading says a dashboard needn't bypass at all. - // Its ADR-0057 D10 widget gate (`filterDashboardForUser`, - // below) is per-DEPLOYMENT — it asks which optional kernel - // services are registered — never per-caller, so there is - // no cross-viewer leak to avoid. What rules out sharing - // the cached path is the validator itself: the ETag is - // `simpleHash(locale + JSON.stringify(item))` over the - // UNFILTERED document (metadata-protocol `getMetaItemCached`), - // so it cannot express the gate dimension at all, and - // `notModified` is decided inside the protocol before this - // layer could re-judge it. Gating the cached body would - // therefore ship a filtered body under a validator that - // identifies the unfiltered one. - // - // That mismatch is not academic, because the two have - // different lifetimes. Within one boot the registered-service - // set is fixed (`Kernel.use()` throws once bootstrap has - // started, and no deregistration API exists), so the gate - // verdict is stable per process — but `Cache-Control: - // private, no-cache` means the client STORES the body and - // revalidates, and that stored body outlives the process. - // A redeploy that turns the optional service off does not - // change the document, so the ETag is unchanged, every - // revalidation answers 304, and the stale unfiltered body - // stands: the dead tile D10 exists to prevent, now cached - // indefinitely. Bypassing costs nothing to weigh against - // that — `getMetaItemCached` delegates to `getMetaItem`, - // so the server does identical work either way and only - // the 304's saved body bytes are given up. - // - // Compared on the NORMALIZED type, like every other - // exclusion in this condition (`/meta/dashboards/x` is - // the canonical plural spelling under Prime Directive - // #3, and an exclusion it could be spelled around would - // not be an exclusion). The `doc` / `book` literals - // that stood at the end of this condition had exactly - // that hole; commit 83a3b1f2e closed it. - const isDashboardType = metaType === 'dashboard'; - // ADR-0046 §6.7 — the two audience-gated types, excluded - // from the cache so {@link metaItemReadGate} judges them. - const isAudienceGatedType = metaType === 'book' || metaType === 'doc'; - // [#22877] A member-addressed read bypasses the cache - // too: the cached read takes no member and keys its - // validator on type and name, so it can only answer the - // canonical member. - const memberScoped = memberSelector.member !== undefined; - if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !memberScoped && !isAudienceGatedType) { - // [ADR-0106 D3] When a projection applies, the - // protocol is NOT allowed to judge the conditional - // request: `getMetaItemCached` hashes the UNFILTERED - // document, so a `304` decided there would pin this - // caller to a body no mask ever touched — the same - // validator-vs-served-body mismatch #5881 recorded - // for the dashboard gate. The comparison moves below, - // against the fingerprinted ETag, which is the one - // that identifies what we are actually sending. - // [#22250] A D4-exempt caller's posture carrying - // `runtime` is one: the definition is served whole, - // but the `sortability` beside it follows that - // caller's own field permission, which the - // protocol's validator does not hash either. - const maskApplies = maskPosture.kind !== 'passthrough' || maskPosture.runtime !== undefined; - // [#21476] Same move for a `view`: its body can carry - // the public-form intake reason, which derives from - // the posture and the bound object, and the - // protocol's validator hashes neither. With no reason - // the folded ETag is byte-identical, so a view's - // `304` answers exactly as before. - const intakeFolds = metaType === 'view'; - // [#22639] And for the two types this arm serves that - // the per-caller read gate judges — a list view (one - // refused whole, a container pruned) and an object - // (its own `listViews` pruned): the body varies per - // caller by what the gate withheld, which the - // protocol's validator does not hash. Nothing - // withheld folds nothing, so the `304` answers as - // before. - const audienceFolds = metaType === 'view' || metaType === 'object'; - const cacheRequest = { - ifNoneMatch: (maskApplies || intakeFolds || audienceFolds) ? undefined : (req.headers['if-none-match'] as string), - ifModifiedSince: req.headers['if-modified-since'] as string, - }; - - // Resolve the response locale up-front and fold it - // into the cache key. The body is translated below - // (`translateMetaItem`) *after* this validator runs, - // so without a locale-aware ETag a language switch - // would return a stale-locale 304 (issue #1319). - const cacheI18n = await this.resolveI18nService(environmentId, req); - const cacheLocale = this.extractLocale(req, cacheI18n); - - // [commit 2a29caa53] Typed request — `as any` retired. The - // cached read carries NO draft-visibility members - // on purpose: this branch is unreachable when a - // draft switch is ADMITTED (`previewDrafts` / - // `isDraftRead` true — the fork above bypasses the - // cache for both), and the implementation's - // `getMetaItemCached` signature declares neither. - // [#20320] The query PARAMETER still reaches here: - // a caller `mayReadPendingDrafts` does not admit - // sends `?state=draft` / `?preview=draft` and is - // answered this arm, the plain read's (#20338). - const cachedRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - cacheRequest, - ...(cacheLocale ? { locale: cacheLocale } : {}), - ...(environmentId ? { environmentId } : {}), - // [#9454] The cached door is the `view` arm, and - }; - const result = await p.getMetaItemCached(cachedRequest); - // [#23316] The automation engine's verdict on a - // FLOW, beside the document and never inside it: - // this branch is the default door of - // `GET /meta/flow/:name`, so without it a flow the - // engine refused was served here as its stored - // `status` alone. The protocol reads it live from - // its delegate read on every call; it is in no - // validator, so a read carrying it is never - // answered 304 (`getMetaItemCached` says why). - // Flow only — every other type's cached envelope - // is unchanged. - const cachedRegistration = metaType === 'flow' && result.registration !== undefined - ? { registration: result.registration } - : {}; - - if (result.notModified) { - res.status(304).send(); - return; - } - - // [ADR-0106 D1/D3] fetch → mask → send. The shared - // cache still stores ONE full schema per (type, - // name, locale, environment) — no caller dimension - // in the key — and what varies per caller is this - // projection plus the validator below. - let cachedDocument: any = result.data; - // [#22639] THE per-caller read gate, before the mask — - // the item chain's order (`createMetaItemAnswer`: gate, - // then mask). This arm serves `view` and `object`, the - // two types it judges here: a list view the caller - // does not hold is refused whole, a container and an - // object are served minus the list views withheld. - // The same judge every other door asks — ⛔ no - // decision of its own here. What it withheld is - // folded into the validator below. - let audienceFingerprint = ''; - if (audienceFolds) { - const verdict = await this.metaItemReadGate( - environmentId, req, p, metaType, req.params.name, [cachedDocument], - { arms: 'all', app: 'gate' }, - )(cachedDocument); - if (verdict.kind === 'refuse') { - verdict.send(res); - return; - } - audienceFingerprint = objectFieldVisibilityFingerprint( - metaReadGate.withheldListViewNames(cachedDocument, verdict.document), - ); - cachedDocument = verdict.document; - } - let visibilityFingerprint = ''; - if (maskPosture.kind === 'project') { - // [#21884] Related to the fetched document: its `objectOverride` params name other objects. - const related = await relateObjectSchemaMaskPosture(maskPosture, cachedDocument); - const masked = this.maskObjectDocument(res, related, req.params.name, cachedDocument); - if (!masked) return; - cachedDocument = masked.document; - visibilityFingerprint = masked.fingerprint; - } - // [#21476] The administrator's read names why an open - // public form is not offered on this posture. - let intakeFingerprint = ''; - if (intakeFolds) { - const warnings = await this.anonymousFormIntakeWarnings(environmentId, req, p, cachedDocument); - cachedDocument = stampAnonymousFormIntakeWarnings(cachedDocument, warnings); - intakeFingerprint = anonymousFormIntakeFingerprint(warnings); - } - // [#22250] The caller's runtime view of what is - // served — the document itself unless the caller is - // D4-exempt and their own field permission withholds - // some of its fields — which `sortability` derives - // from, and whose fingerprint the validator folds. - const runtimeView = await resolveObjectSchemaRuntimeView(cachedDocument, maskPosture); - - // [ADR-0106 D6 tier 2] Visibility undetermined → - // the body is unmasked, so it must not be stored or - // revalidated under a SHARED validator: a later 304 - // would hand this body to a caller whose projection - // did resolve. No ETag, no Last-Modified, no-store. - if (maskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); - res.header('Vary', 'Accept-Language'); - res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, - { type: metaType, name: req.params.name, ...cachedRegistration }, - cachedDocument, cacheI18n, runtimeView.document, - )); - return; - } - - // Set cache headers - if (result.etag) { - // [ADR-0106 D3] Fold the caller's field-visibility - // fingerprint into the shared validator. An - // unrestricted caller denies nothing → the - // fingerprint is empty → the ETag is byte-identical - // to the pre-ADR one. A cohort shares 304s; a - // permission change moves the fingerprint and - // self-invalidates the stale 304. [#22250] So does - // the runtime view's: a D4-exempt caller whose - // permission withholds a field is served a - // different `sortability`, never a 304 for another's. - // [#22639] …and so does what the read gate - // withheld: a caller who no longer holds a list - // view's capability is never answered a `304` - // for the body that carried it. - const value = foldVisibilityFingerprintIntoEtag( - foldVisibilityFingerprintIntoEtag( - foldVisibilityFingerprintIntoEtag( - foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), - runtimeView.fingerprint, - ), - intakeFingerprint, - ), - audienceFingerprint, - ); - const etagValue = result.etag.weak - ? `W/"${value}"` - : `"${value}"`; - res.header('ETag', etagValue); - if ((maskApplies || intakeFolds || audienceFolds) && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { - res.status(304).send(); - return; - } - } - if (result.lastModified) { - res.header('Last-Modified', new Date(result.lastModified).toUTCString()); - } - if (result.cacheControl) { - // `max-age` is a placeholder directive in the - // array; its real value is appended from the - // `maxAge` field. Strip the bare token before - // joining so the two never collide into the - // malformed `public, max-age, max-age=3600`. - const parts: string[] = result.cacheControl.directives - .filter((d: string) => d !== 'max-age'); - if (result.cacheControl.maxAge != null) { - parts.push(`max-age=${result.cacheControl.maxAge}`); - } - res.header('Cache-Control', parts.join(', ')); - } - - res.header('Vary', 'Accept-Language'); - // [#5563] `getMetaItemCached` hands back the metadata - // document with the envelope already stripped - // (`result.data`; it does `const item = result?.item` - // internally). This branch is the DEFAULT — `enableCache` - // defaults to `true` — so leaving it unwrapped made the - // one shape `packages/spec` declares for this route the - // one a default deployment could never obtain. Rebuild - // the declared envelope here, in the REST layer that - // owns the response contract. - // - // `type` is folded to the canonical singular exactly as - // `metadata-protocol` folds it (#4432), so `/meta/objects/x` - // and `/meta/object/x` cannot answer two different `type` - // values across a configuration switch. The cached read - // carries no `lock` — it is the fast published-value path - // and never consulted the lock resolver; a caller that - // needs the ADR-0008 OCC carriers reads the uncached path. - // [#22114] That includes `version`, the read's token. Its - // ETag above stays the CACHE validator and is not the token: - // the validator varies by locale (#1319), by the caller's - // field visibility (ADR-0106 D3) and by the served bytes - // (`getMetaItemCached`, #16525), none of which moves the - // stored row's version, and an item with no stored row has - // a validator but no version at all. - const cachedEnvelope = { - type: metaType, - name: req.params.name, - ...cachedRegistration, - }; - res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, - runtimeView.document, - )); - } else { - // Non-cached version - // [#22128] The save door's reading of `?package=` - // ({@link metaItemPackageBinding}): the served - // `version` is resolved at the address that save - // writes, so `all` names no package here either. - const packageId = metaItemPackageBinding(req.query?.package); - // [commit 2a29caa53] Typed against the spec request shape — - // the `as any` this literal used to carry is - // retired now that the spec declares `state` and - // `previewDrafts`. - // No transport envelope: this door does not thread - // `environmentId` (the kernel was already resolved - // above), so the plain declared shape suffices. - const itemRequest: GetMetaItemRequest = { - type: req.params.type, - name: req.params.name, - packageId, - // [#20338] The ADMITTED switches declared above, - // never `req.query` re-read here. - ...(isDraftRead ? { state: 'draft' as const } : {}), - ...(previewDrafts ? { previewDrafts: true } : {}), - // [#22877] The member, read once above. - ...memberSelector, - }; - const envelope = await p.getMetaItem(itemRequest) as Record; - - // [#20408] Everything this read does to the envelope - // after the store read is ONE chain, - // `createMetaItemAnswer` in `./meta-item-read-gate.ts`, - // which the runtime dispatcher's `/meta` item read calls - // too. Its docblock carries each step's rule and their - // order — [#18066] absence judged BEFORE the gate (a name - // with nothing behind it can never become #8013's `403`), - // [#20156] THE per-caller read gate, the ADR-0046 doc - // locale collapse, the [ADR-0106 D1/D5(1)] mask under the - // posture resolved above (this uncached exit serves - // `?state=draft`, `?preview=draft`, `?package=` and every - // `enableCache: false` deployment, so a mask living only - // in the cached arm would be walked past by a query - // parameter), and the body: the translation and #10235's - // `sortability`. ⛔ An item step is added there, never - // here (`meta-list-projection-parity.test.ts` in - // `@objectstack/runtime` derives its census from this - // handler and fails on one added here). - // - // The gate's policy is this read's: every arm and the app - // PRUNED, to every caller, its authors included - // (read-to-display is per user) — save [#20290] the - // `?state=draft` branch, which serves a STORED version - // (the pending draft row) and so reads under the - // stored-version doors' policy - // ({@link STORED_VERSION_DOOR_POLICY}): Studio's designers - // merge this answer over the layered view and save it - // back, so whoever may save the app reads its draft - // whole, every other caller pruned per caller (ruling - // 5856774816), and no per-DEPLOYMENT gate. [#20338] "Every - // other caller" is every other caller who may READ - // drafts: one who may not never reaches this branch - // (`isDraftRead` is false for them). - const readPolicy: MetaReadGatePolicy = isDraftRead - ? RestServer.STORED_VERSION_DOOR_POLICY - : { arms: 'all', app: 'gate' }; - const answer = await metaReadGate.createMetaItemAnswer( - this.metaItemAnswerSources(environmentId, req, p, readPolicy), - { metaType, name: req.params.name, policy: readPolicy, maskPosture }, - )(envelope); - if (answer.kind === 'refuse') { - // `absent` is {@link sendMetaItemAbsent}, byte for byte. - RestServer.sendMetaReadRefusal(res, answer.refusal); - return; - } - if (answer.kind === 'mask-fault') { - sendFieldVisibilityFault(res, answer.object); - return; - } - // [ADR-0106 D6 tier 2] Visibility undetermined → the - // body is unmasked, so it must not be stored or - // revalidated under a shared validator. - if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); - res.header('Vary', 'Accept-Language'); - res.json(answer.envelope); - } - } catch (error: any) { - // [#18402] THE one absence answer, whichever arm - // produced it — the last half of #18066. - // - // #18066 gave this route a single absence EMITTER - // ({@link sendMetaItemAbsent}) and reached it from the - // two conditions that RETURN nothing. The conditions - // that THROW one were left on the classification door - // below, which renders the flat `{ error: '', - // code }` — so `body.error.code`, the accessor #8013 - // settled on and objectui#4252 reads, was `undefined` - // on exactly those. Which one a caller got was decided - // by two things it cannot see: - // - // - `metadata.enableCache` (default TRUE). The cached - // arm's `getMetaItemCached` THROWS - // `metadataItemNotFoundError` on a falsy `item`; the - // uncached arm resolves item-less and returns. One - // request, one missing name, two envelopes, chosen - // by a server setting — the #7035 failure class. - // - which protocol implementation is mounted. The - // in-repo `metadata-protocol` resolves item-less - // from `getMetaItem`, but a protocol that throws the - // miss instead reached the same flat door - // (pinned in `rest-meta-outage-vs-miss.test.ts`). - // - // Recognised by the ANSWER this repo's own - // classification door would have given — see - // {@link thrownAnswerIsBareNotFound}, which asks that - // door rather than re-reading the error, so this fork - // and the `handleRouteError` it forks away from cannot - // drift apart about what a caught value means. - // - // ⛔ NOT "the status is 404", and that narrowing was - // measured rather than assumed. `NO_DRAFT` is a 404 on - // THIS route — the Studio designer's `?state=draft` - // probe, pinned byte-for-byte two files over — and it - // says the item IS there and its draft is not. - // Answering it as absence would tell a designer the - // object does not exist, which is #5532's flattening - // reintroduced by the repair for a sibling of it. Same - // reasoning excludes a producer-declared code the - // ledger does not know: ADR-0112 keeps that spelling in - // `declaredCode`, and converting would delete it. - // - // ⭐ It STRENGTHENS the ADR-0045 §3 property rather - // than merely preserving it. The unpublished app and - // the service-gated one answer through the emitter, so - // an absence that kept the thrown dialect was a - // response pair that told them apart — by envelope - // shape, and by the producer's `Metadata item - // / not found` prose where the emitter says - // one fixed sentence. Four arms, one body now; the - // byte-identity is pinned in - // `meta-item-absent-404.test.ts` §2 and §5. - // - // ⛔ NOT a convergence of the flat dialect itself. The - // audience gate's `sendDeclaredFault` 401/403 beside - // this, and the door in `error-response.ts`, still - // answer flat: that position is the live ratchet - // #9559 owns repo-wide (`check:route-envelope`), and - // converting two of its four emissions here would mint - // a new divergence — the same refusal answering two - // shapes depending on which ROUTE served it. - if (thrownAnswerIsBareNotFound(error)) { - sendMetaItemAbsent(res); - return; - } - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Get specific metadata item', - tags: ['metadata'], - }, - }); - } + // [#23050 slice 13b] The per-item reads (`GET {metaPath}/:type/:name/references`, + // `/layers`, `GET {metaPath}/book/:name/tree` and `GET {metaPath}/:type/:name`), + // with the per-item switch's `if` that gated them, moved to + // `meta-item-read-routes.ts`, and are registered HERE so they keep their place in + // the order: after the per-type list, above the item writes below. This literal + // hands that body every member it reads (see `MetaItemReadRouteHost`), built + // inside this method for the same reason as the one above: the route manager it + // captures is the umbrella's wrapping registrar. Read that module's header before + // moving it. + const itemReadHost: MetaItemReadRouteHost = { + routeManager: this.routeManager, + resolveProtocol: (...a) => this.resolveProtocol(...a), + metaTypeSingular: (...a) => RestServer.metaTypeSingular(...a), + resolveObjectMasker: (...a) => this.resolveObjectMasker(...a), + serveMetaItemLayered: (...a) => this.serveMetaItemLayered(...a), + metaReadAudienceSources: (...a) => this.metaReadAudienceSources(...a), + extractLocale: (...a) => this.extractLocale(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + resolveI18nService: (...a) => this.resolveI18nService(...a), + metaItemReadGate: (...a) => this.metaItemReadGate(...a), + maskObjectDocument: (...a) => this.maskObjectDocument(...a), + anonymousFormIntakeWarnings: (...a) => this.anonymousFormIntakeWarnings(...a), + translateMetaEnvelope: (...a) => this.translateMetaEnvelope(...a), + STORED_VERSION_DOOR_POLICY: RestServer.STORED_VERSION_DOOR_POLICY, + metaItemAnswerSources: (...a) => this.metaItemAnswerSources(...a), + sendMetaReadRefusal: (...a) => RestServer.sendMetaReadRefusal(...a), + }; + registerMetaItemReadRoutes.call(itemReadHost, scope); // PUT /meta/:type/:name - Save metadata item // We always register this route, but return 501 if protocol doesn't support it From 8a8a105cb688880b0577c8bdcc389d8826d85459 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:29:48 +0000 Subject: [PATCH 2/5] test(rest): add meta-item-read-routes.ts to the registrar list and its /meta subset Sub-slice 13b, the list commit. The new module joins REST_SERVER_REGISTRAR_FILES (14 -> 15) and REST_SERVER_META_ROUTE_FILES (1 -> 2), so every whole-file reader of the list widens to it. The readers still carry their base numbers here; they are re-pointed in the next commit. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/rest/src/rest-server-registrar-files.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index c08d594b569..bbff5b71826 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -56,6 +56,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#23050 slice 13a] The first three `/meta` groups of // `registerMetadataEndpointsInner`' body — see {@link REST_SERVER_META_ROUTE_FILES}. 'meta-collection-routes.ts', + // [#23050 slice 13b] The fourth `/meta` group of `registerMetadataEndpointsInner`' + // body, the per-item reads — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-item-read-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -81,6 +84,9 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // [#23050 slice 13a] The type list, the whole-store maintenance family and the // per-type list. 'meta-collection-routes.ts', + // [#23050 slice 13b] The per-item reads: `/references`, `/layers`, the book tree + // and the item read. + 'meta-item-read-routes.ts', ]; /** From 5cb0f9c9bed7124ec9da3fc5d8e5f1e1ccb7c29f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:35:34 +0000 Subject: [PATCH 3/5] test(rest): re-point every reader of the moved per-item reads Sub-slice 13b, the re-point commit. Each reader whose reading includes a moved line now reads meta-item-read-routes.ts, with its sum unchanged: - the registrar-list readers (execctx census 62 sites / 137 mentions, draft census 8, query-number census 9, canonical query-AST census 4, docblock floors 59, the ui-view measurements) pin length 15; - the dogfood blind-spot census keeps 70 / 19 / 51 with a per-module control of 4, and the realtime tripwires read 0 over fifteen files; - the runtime parity test finds the item, tree and layers handlers in the module and the layered helper in the class; - check-route-envelope declares the module at 1 / 1 and rest-server.ts goes 8 / 4 to 7 / 3, sums unchanged; - the liveness rows for enableCache, endpoints.item, doc content and the doc and book notes, and the api-backend and platform-core checklist anchors, cite the module; - a new ADR anchor for the module keeps check-adr-anchors' decision-letter reading at 1776, which the move alone dropped to 1760 while green. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../platform-checklist/areas/api-backend.json | 2 +- .../areas/platform-core.json | 2 +- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 26 ++++++++-- .../rest/src/execctx-consumer-census.test.ts | 28 +++++++++-- .../src/meta-draft-read-door-census.test.ts | 6 ++- .../rest-server-canonical-query-ast.test.ts | 9 +++- .../src/rest-server-docblock-position.test.ts | 15 +++++- .../rest-server-query-number-census.test.ts | 6 ++- ...ui-view-route-identity.measurement.test.ts | 7 ++- .../ui-view-route-tenancy.measurement.test.ts | 8 +++- .../meta-list-projection-parity.test.ts | 48 +++++++++++-------- packages/spec/liveness/book.json | 2 +- packages/spec/liveness/doc.json | 4 +- .../spec/liveness/metadata_endpoints.json | 4 +- ...__rest__src__meta-item-read-routes.ts.json | 8 ++++ scripts/check-route-envelope.mjs | 35 +++++++++++++- 17 files changed, 166 insertions(+), 47 deletions(-) create mode 100644 scripts/adr-anchors/packages__rest__src__meta-item-read-routes.ts.json diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index fb8038ecd4a..64dc8e2f722 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -2446,7 +2446,7 @@ "source": [ "packages/spec/liveness/metadata_endpoints.json (the ADR-0049 ledger this item answers: prefix, enableCache, maskObjectFields and three endpoints.* live, cacheTtl and endpoints.schema dead)", "packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema (the declared contract — four endpoints.* switches whose describe() strings enumerate every mount they gate since #15542/#15854, the ADR-0106 D8 docblock and the two retiredKey tombstones)", - "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath, the item endpoint gate and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (getRoutes) + packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (the types, items and maintenance endpoint gates) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.metadata built from prefix)", + "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (metaPath and registerPerItemRoute — the per-item switch spelled as a call so the later members of that face carry it), (resolveObjectMasker — the metaType/maskObjectFields fork), (normalizeConfig — isObjectSchemaMaskingEnabled and the OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch), (getRoutes) + packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes (the types, items and maintenance endpoint gates) + packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes (the item endpoint gate on the per-item reads) + packages/rest/src/discovery-routes.ts#registerDiscoveryEndpoints (discovery.routes.metadata built from prefix)", "ADR-0106 D8 (per-caller field masking of served object schemas) · #14691 (the two retirements) · #7526 (the /meta/types ordering fix the `types` switch takes with it) · ADR-0076 D12 · #15542 / #15854 (the switch-radius ruling that created `maintenance` and gave `item` its own writes) · #15543 (why the measured blast radius of that move is zero: no shipped boot path authors a RestServerConfig)", "sibling clause: platform-core.metadata-registry-serving (it drives the served REGISTRY on the default config; this item covers the config that decides what is served and to whom) · platform-core.docs-audience-gate (the audience-gated types that are a cache carve-out here)" ], diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 68dc41592db..88de0c9c457 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1263,7 +1263,7 @@ "ADR-0090 (audience vocabulary)", "ADR-0049 (fail closed — unresolvable holdings deny)", "packages/spec/src/system/book.zod.ts#audienceAllows — audienceAllows / resolveDocAudiences / docAudienceAllows / resolveBookClaimedDocs / deriveImplicitPackageBook / resolveBookTree (UNCATEGORIZED_KEY exclusion)", - "packages/rest/src/rest-server.ts#metaPath — GET /book/:name/tree (audience gate, per-entry filter, resolveDocLocale, refuseRepeatedQueryParams)", + "packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes — GET /book/:name/tree (audience gate, per-entry filter, resolveDocLocale, refuseRepeatedQueryParams)", "#2774 (feat(security)!: enforce book audience at the REST read layer)", "packages/spec/liveness/book.json + doc.json (the ledgers whose coverage.json waivers this item retires)" ], diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index c10ed35721b..8c22f627505 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1635,7 +1635,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 11] Length 11 → 12 (`form-routes.ts`), still 0 over it. // [#23050 slice 12] Length 12 → 13 (`crud-routes.ts`), still 0 over it. // [#23050 slice 13a] Length 13 → 14 (`meta-collection-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] Length 14 → 15 (`meta-item-read-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 627ee6c5ecc..7f93f030b02 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -540,16 +540,18 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + - '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts` and `meta-collection-routes.ts`, ' + + '`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts`, `meta-collection-routes.ts` and ' + + '`meta-item-read-routes.ts`, ' + 'the modules the bodies of ' + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + - '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first three `/meta` ' + + '`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first four `/meta` ' + 'groups of `registerMetadataEndpointsInner`, moved to; ' + 'reachable = those inside registerMetadataEndpoints, PLUS every site in the registrar list\'s named `/meta` ' + - 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`), whose registrations the inner method ' + + 'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts` and `meta-item-read-routes.ts`), whose ' + + 'registrations the inner method ' + 'runs inside the umbrella\'s swap', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -849,10 +851,25 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // `private register*Endpoints(` stays 16, `registerPerItemRoute(` stays 8 and // `enforceAuth` stays 112: the moved groups spell neither term, and the seam // spells neither (the umbrella's wrapper is what gates them). + // [#23050 slice 13b] 70 / 19 / 51 UNCHANGED, and `reachable` holds by the rule + // 13a taught, with no rule change: the fourth `/meta` group, the per-item reads + // (four direct registration sites: `/references`, `/layers`, the book tree and + // the item read, all REACHABLE through the same umbrella key), moved to + // `meta-item-read-routes.ts`, which joins the list (length 14 -> 15) and the + // named `/meta` subset (1 -> 2) with its own control. The inner method builds + // its host literal inside the umbrella's swap and calls the module at the + // group's old position, so the subset's length and the module calls inside the + // inner method move together (both 2). Measured at the move commit, before the + // list learned the module: population 66 and `this.routeManager.register(` 59 + // over fourteen files, the four sites out of reach. `this.routeManager.register(` stays 63 + // over the fifteen files (2 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1 + 1 + 2 + + // 6 + 6 + 4), `private register*Endpoints(` stays 16, `registerPerItemRoute(` + // stays 8 and `enforceAuth` stays 112: the moved group spells neither term, + // and neither does the seam. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 14, + 'registrar list length': 15, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -866,6 +883,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'form-routes.ts: this.routeManager.register(': 2, 'crud-routes.ts: this.routeManager.register(': 6, 'meta-collection-routes.ts: this.routeManager.register(': 6, + 'meta-item-read-routes.ts: this.routeManager.register(': 4, // [#23050 slice 13a] Recorded AS the subset's length, not as a literal: the // measured side is the module calls inside the inner method, so the two // can only move together. diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 835f8398c0d..4b1649f70d6 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -124,6 +124,11 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * `registerMetadataEndpointsInner`' body to `meta-collection-routes.ts`, with * their three: the `_drafts` and `_migrate-stored` doors' and the per-type * list's, each locally caught (the list's on its continuation line), as before. + * + * [#23050 slice 13b] …and the fourth, the per-item reads, to + * `meta-item-read-routes.ts`, with its one: the item read's, locally caught on + * its continuation line, as before — one of §7's and §8's four continuation + * sites, which those sections find there because they read every file here. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -139,9 +144,9 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; * `registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, * `registerOpenApiEndpoints`, `registerEmailEndpoints`, * `registerSearchEndpoints`, `registerUiEndpoints`, - * `registerCrudEndpoints`) hands its moved body — and the one - * `registerMetadataEndpointsInner` builds for its moved `/meta` groups (#23050 - * slice 13a), counted as the moved module's registrar — + * `registerCrudEndpoints`) hands its moved body — and each one + * `registerMetadataEndpointsInner` builds for a moved `/meta` group's module + * (#23050 slices 13a and 13b), counted as that module's registrar — * forwards each member to the server with `(...a) => this.(...a)`, so * a moved site's stack shows * that forwarder above the site itself. The site is the first censused frame @@ -748,9 +753,22 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // forwarder's two mentions in). Fourteen forwarders now: (14 - 1) + 3 - 2, // the module carrying one host literal that reads the seam, so neither map // gains an entry. - expect(SITE_FILES).toHaveLength(14); + // + // [#23050 slice 13b] 62 sites UNCHANGED / 133 → 137 mentions — the same MOVE. + // The fourth `/meta` group, the per-item reads, left for + // `meta-item-read-routes.ts` with its one LOCALLY CAUGHT site (the item read's, + // on its continuation line, as before): + // 12 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 + 1 = 62 sites. The + // body also carries one prose mention, which moved with it. The four new + // mentions are this slice's seam, the two spellings once (the second host + // literal the inner method builds, its forwarder, two; the host interface's + // member, two): `rest-server.ts` stays at 55 (the site and the prose mention + // out, one forwarder's two mentions in). Fifteen forwarders now: + // (15 - 1) + 3 - 2, the module carrying one host literal that reads the + // seam, so neither map gains an entry. + expect(SITE_FILES).toHaveLength(15); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(133); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(137); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); expect(extraMovedRegistrars).toBe(3); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 738979c86db..83a8190ceb4 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -268,7 +268,11 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // per-type list's `?preview=draft`. A key names its route, never its file, // so no ledger row changed: // 8 = 3 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3, length 13 → 14. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] The per-item reads moved to `meta-item-read-routes.ts` + // with two more: the item read's `?state=draft` and `?preview=draft`. Keys + // name the route, so again no ledger row changed: + // 8 = 1 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 3 + 2, length 14 → 15. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index b6bd1965f96..03a14c156cb 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -178,6 +178,10 @@ interface CensusEntry { * no `query:` slot. The per-type list's one `query` key sits mid-line in the * list chain's options literal, which `querySlots` (a line-initial `query:`) * does not read, as before the move, so the moved module's floor is 0. + * + * [#23050 slice 13b] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0, + * unchanged: the per-item reads (`meta-item-read-routes.ts`) build no `query:` + * slot either, so the moved module's floor is 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -194,6 +198,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // from `rest-server.ts` to `crud-routes.ts`). // [#23050 slice 13a] Length 13 → 14, the sum unchanged (the moved `/meta` // groups carry no slot). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] Length 14 → 15, the sum unchanged (the per-item reads + // carry no slot). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index d1f438e20e7..ee8b76d5023 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -200,6 +200,14 @@ describe('rest-server.ts docblock position', () => { * function's. `ui-routes.ts` therefore gives up that block (4 → 3). The moved * `/meta` groups held no TSDoc block of their own, so `rest-server.ts` keeps * every block it had (163). + * + * [#23050 slice 13b] `meta-item-read-routes.ts` carries five: the module + * header, the host interface's block, the function's, and the blocks of the two + * module-level helpers only the per-item reads called, which moved with them + * (the reference sweep's declared-refusal re-dresser and the intake reasons' + * ETag dimension). The moved `/meta` group held no TSDoc block of its own, so + * `rest-server.ts` gives up exactly those two (163 → 161), still far above its + * floor. */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -215,6 +223,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -253,9 +262,11 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 12] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 + 3 = 50 over twelve, length 12 → 13. // [#23050 slice 13a] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 = 54 over // thirteen, length 13 → 14 (`ui-routes.ts` 4 → 3: the alias's block moved on). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 3 + 3 + 3 + 5 + 5 = 59 over + // fourteen, length 14 → 15. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(54); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(59); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index 5ccde7c00e1..99411f4ad4d 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -343,7 +343,11 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // (`meta-collection-routes.ts`): the type list, the whole-store family and // the per-type list coerce no number of the query: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 13 → 14. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] Nor do the per-item reads (`meta-item-read-routes.ts`): + // the reference sweep, the layered read, the book tree and the item read + // coerce no number of the query: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 14 → 15. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index 7ac629b5c19..fd247bd0b52 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -380,7 +380,12 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // sites and `enforceAuth` at none (the umbrella's wrapper gates them), so // the host literal the inner method builds forwards the first only; the // three call sites moved and are counted in both readings. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] 14 → 15 (`meta-item-read-routes.ts`): 60 unchanged + // and 76 → 77, the same shape: the per-item reads call `resolveExecCtx` at + // one site and `enforceAuth` at none, so the second host literal the inner + // method builds forwards the first only; the call site moved and is + // counted in both readings. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 2aae98faa64..1c2a900e594 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1153,7 +1153,13 @@ describe('[#13214] §4 preconditions', () => { // three call sites moved and are counted in both readings. The // moved groups never call the ownership guard, so it is still 1 call site // and 1 forwarder. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(14); + // [#23050 slice 13b] 14 → 15 (`meta-item-read-routes.ts`): 60 unchanged + // and 76 → 77, the same shape: the per-item reads call `resolveExecCtx` at + // one site and `enforceAuth` at none, so the second host literal the inner + // method builds forwards the first only; the call site moved and is + // counted in both readings. The per-item reads never call the ownership + // guard either, so it is still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(15); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index a47913246c5..98f6fc257b9 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -509,9 +509,13 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const REST_SERVER_SOURCE = resolve(HERE, '../../../rest/src/rest-server.ts'); // [#23050 slice 13a] `RestServer`'s `GET ${metaPath}/:type` handler moved, with the // type list and the whole-store family, from `registerMetadataEndpointsInner` to -// `meta-collection-routes.ts`; the list derivation reads it there. The item, tree -// and layers handlers are still in `rest-server.ts`. +// `meta-collection-routes.ts`; the list derivation reads it there. const META_COLLECTION_ROUTES_SOURCE = resolve(HERE, '../../../rest/src/meta-collection-routes.ts'); +// [#23050 slice 13b] …and its item, tree and layers handlers moved, with the +// reference sweep, to `meta-item-read-routes.ts`; the route derivation reads them +// there. The private helper both layered spellings call (`serveMetaItemLayered`) +// is still a `RestServer` method, read in `rest-server.ts`. +const META_ITEM_READ_ROUTES_SOURCE = resolve(HERE, '../../../rest/src/meta-item-read-routes.ts'); const SHARED_CHAIN_SOURCE = resolve(HERE, '../../../rest/src/meta-item-read-gate.ts'); interface Derived { params: Set; types: Set; found: boolean } @@ -878,23 +882,25 @@ const LAYERED_METHODS = ['serveMetaItemLayered']; function deriveRouteReads(routePath: string, sharedFunctions: readonly string[], methods: readonly string[] = []): Derived { const derived: Derived = { params: new Set(), types: new Set(), found: false }; - const rest = parse(REST_SERVER_SOURCE); - const visit = (n: ts.Node): void => { - if (ts.isObjectLiteralExpression(n)) { - const method = propertyInitializer(rest, n, 'method'); - const path = propertyInitializer(rest, n, 'path'); - const handler = propertyInitializer(rest, n, 'handler'); - if (method?.getText(rest) === "'GET'" && path?.getText(rest) === routePath && handler) { - derived.found = true; - readsUnder(rest, handler, derived); + // [#23050 slice 13b] The handler is found in the module, the helper method in the class. + for (const rest of [parse(META_ITEM_READ_ROUTES_SOURCE), parse(REST_SERVER_SOURCE)]) { + const visit = (n: ts.Node): void => { + if (ts.isObjectLiteralExpression(n)) { + const method = propertyInitializer(rest, n, 'method'); + const path = propertyInitializer(rest, n, 'path'); + const handler = propertyInitializer(rest, n, 'handler'); + if (method?.getText(rest) === "'GET'" && path?.getText(rest) === routePath && handler) { + derived.found = true; + readsUnder(rest, handler, derived); + } } - } - // [#20478] A private helper the handler delegates to reads its own - // parameters a frame down (`serveMetaItemLayered` reads `?package=`). - if (ts.isMethodDeclaration(n) && ts.isIdentifier(n.name) && methods.includes(n.name.text)) readsUnder(rest, n, derived); - ts.forEachChild(n, visit); - }; - visit(rest); + // [#20478] A private helper the handler delegates to reads its own + // parameters a frame down (`serveMetaItemLayered` reads `?package=`). + if (ts.isMethodDeclaration(n) && ts.isIdentifier(n.name) && methods.includes(n.name.text)) readsUnder(rest, n, derived); + ts.forEachChild(n, visit); + }; + visit(rest); + } const chain = parse(SHARED_CHAIN_SOURCE); for (const s of chain.statements) { if (ts.isFunctionDeclaration(s) && s.name && sharedFunctions.includes(s.name.text)) readsUnder(chain, s, derived); @@ -908,9 +914,9 @@ describe('[#20408] the item and book-tree census axes cover every read of RestSe const layers = deriveRouteReads('`${metaPath}/:type/:name/layers`', SHARED_LAYERS_FUNCTIONS, LAYERED_METHODS); it('every handler is found where the census looks for it', () => { - expect(item.found, `GET \${metaPath}/:type/:name in ${REST_SERVER_SOURCE}`).toBe(true); - expect(tree.found, `GET \${metaPath}/book/:name/tree in ${REST_SERVER_SOURCE}`).toBe(true); - expect(layers.found, `GET \${metaPath}/:type/:name/layers in ${REST_SERVER_SOURCE}`).toBe(true); + expect(item.found, `GET \${metaPath}/:type/:name in ${META_ITEM_READ_ROUTES_SOURCE}`).toBe(true); + expect(tree.found, `GET \${metaPath}/book/:name/tree in ${META_ITEM_READ_ROUTES_SOURCE}`).toBe(true); + expect(layers.found, `GET \${metaPath}/:type/:name/layers in ${META_ITEM_READ_ROUTES_SOURCE}`).toBe(true); }); it('every query parameter the item read reads is a census cell — [#20478] `?layers=` included, with no exclusion left', () => { diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index e93db13fa3c..b9035cb439c 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -1,6 +1,6 @@ { "type": "book", - "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (`packages/rest/src/rest-server.ts`, the `book/:name/tree` branch of `registerMetadataEndpointsInner`) driving the spec's pure `resolveBookTree` / `audienceAllows` (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). `groups[].translations` is dead: an inline map that LOOKS like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but has no resolver anywhere. Its book-level twin was the same trap and was retired in the same pass (#4667) — that one is a strict deletion, so it left the walked shape and holds no row here, while the group-level key is TOMBSTONED (`retiredKey`; BookGroupSchema is a plain z.object with no `.strict()`) and stays in the shape this ledger walks. No total is restated in this header: `state-counts/book.md` publishes the counts and `check:liveness` proves that shard fresh on every run. Seeded 2026-08-01 (#4488). 2026-08-28 (commit 8cb96ec41): every LOCAL citation in this file was re-anchored to its consuming symbol. The four objectui-only entries (`description`, `slug`, `icon`, `order`) are left BYTE-FOR-BYTE UNTOUCHED and undated-forward on purpose: their evidence is pinned at `objectui @940ba24`, a commit this container cannot reproduce, and the anchor grammar deliberately never collects foreign anchors — re-stamping `verifiedAt` on a call graph nobody re-closed is exactly the false confidence this ledger exists to prevent (the `tool.json` precedent).", + "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (`packages/rest/src/meta-item-read-routes.ts`, the `book/:name/tree` branch of `registerMetaItemReadRoutes`) driving the spec's pure `resolveBookTree` / `audienceAllows` (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). `groups[].translations` is dead: an inline map that LOOKS like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but has no resolver anywhere. Its book-level twin was the same trap and was retired in the same pass (#4667) — that one is a strict deletion, so it left the walked shape and holds no row here, while the group-level key is TOMBSTONED (`retiredKey`; BookGroupSchema is a plain z.object with no `.strict()`) and stays in the shape this ledger walks. No total is restated in this header: `state-counts/book.md` publishes the counts and `check:liveness` proves that shard fresh on every run. Seeded 2026-08-01 (#4488). 2026-08-28 (commit 8cb96ec41): every LOCAL citation in this file was re-anchored to its consuming symbol. The four objectui-only entries (`description`, `slug`, `icon`, `order`) are left BYTE-FOR-BYTE UNTOUCHED and undated-forward on purpose: their evidence is pinned at `objectui @940ba24`, a commit this container cannot reproduce, and the anchor grammar deliberately never collects foreign anchors — re-stamping `verifiedAt` on a call graph nobody re-closed is exactly the false confidence this ledger exists to prevent (the `tool.json` precedent).", "props": { "name": { "status": "live", diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json index 039268409e3..a4bae718801 100644 --- a/packages/spec/liveness/doc.json +++ b/packages/spec/liveness/doc.json @@ -1,6 +1,6 @@ { "type": "doc", - "_note": "DocSchema (ADR-0046 flat Markdown package docs). Fully live. The schema header calls docs 'inert data' — true of the KERNEL (it stores `content` unparsed), but every property has a real runtime consumer in the delivery layer: the REST read layer localizes, audience-gates and serves docs (`packages/rest/src/rest-server.ts`, inside `registerMetadataEndpointsInner` — the `doc` list branch, the `/meta/book/:name/tree` branch and the single-item branch), and the tree endpoint resolves book membership from doc headers via the spec's own `resolveBookTree` (packages/spec/src/system/book.zod.ts). objectui's console docs portal is a faithful port of the same resolver (apps/console/src/pages/book-nav.ts @940ba24) rendering the reader UI — a delivery surface for readers, NOT an authoring preview. Seeded 2026-08-01 (#4488). NOTE: `tags` was DECLARED in 17.0.0 (#4509, ADR-0049) — the enforce half of enforce-or-remove: the book-side `include: { tag }` rule and the REST corpus (`tags: d.tags`) both already expected the key, so declaring it made the previously-inert `{ tag }` include variant live; see the `tags` entry below for the fix's full history. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol, and this file needed it most in the batch — ALL FIFTEEN of its line citations had rotted, because both cited files were reorganized under it. In `book.zod.ts` the pointers had come to rest inside the `ResolverDoc` / `ResolvedEntry` INTERFACES, i.e. on type declarations of the very fields whose consumers they claimed to cite; in `rest-server.ts` the entire doc-serving block moved ~1,600 lines down (the old 2,9xx-3,3xx pointers now land in the batch-endpoints and server-registration regions). Neither move is visible to the existence check, the line bound or the key-mention check.", + "_note": "DocSchema (ADR-0046 flat Markdown package docs). Fully live. The schema header calls docs 'inert data' — true of the KERNEL (it stores `content` unparsed), but every property has a real runtime consumer in the delivery layer: the REST read layer localizes, audience-gates and serves docs (`packages/rest/src/rest-server.ts`, inside `registerMetadataEndpointsInner` — the `doc` list branch — and `packages/rest/src/meta-item-read-routes.ts`, inside `registerMetaItemReadRoutes` — the `/meta/book/:name/tree` branch and the single-item branch), and the tree endpoint resolves book membership from doc headers via the spec's own `resolveBookTree` (packages/spec/src/system/book.zod.ts). objectui's console docs portal is a faithful port of the same resolver (apps/console/src/pages/book-nav.ts @940ba24) rendering the reader UI — a delivery surface for readers, NOT an authoring preview. Seeded 2026-08-01 (#4488). NOTE: `tags` was DECLARED in 17.0.0 (#4509, ADR-0049) — the enforce half of enforce-or-remove: the book-side `include: { tag }` rule and the REST corpus (`tags: d.tags`) both already expected the key, so declaring it made the previously-inert `{ tag }` include variant live; see the `tags` entry below for the fix's full history. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol, and this file needed it most in the batch — ALL FIFTEEN of its line citations had rotted, because both cited files were reorganized under it. In `book.zod.ts` the pointers had come to rest inside the `ResolverDoc` / `ResolvedEntry` INTERFACES, i.e. on type declarations of the very fields whose consumers they claimed to cite; in `rest-server.ts` the entire doc-serving block moved ~1,600 lines down (the old 2,9xx-3,3xx pointers now land in the batch-endpoints and server-registration regions). Neither move is visible to the existence check, the line bound or the key-mention check.", "props": { "name": { "status": "live", @@ -23,7 +23,7 @@ "content": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/doc.zod.ts#resolveDocLocale (`content: variant.content` — the locale swap, and the ONLY field with no per-field fallback: a variant that omits it yields undefined by design); packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (the `doc` list branch destructures `const { content: _content, ...rest } = it` unless `?include=content`; the single-item branch returns the collapsed doc whole)", + "evidence": "packages/spec/src/system/doc.zod.ts#resolveDocLocale (`content: variant.content` — the locale swap, and the ONLY field with no per-field fallback: a variant that omits it yields undefined by design); packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (the `doc` list branch destructures `const { content: _content, ...rest } = it` unless `?include=content`); packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes (the single-item branch returns the collapsed doc whole)", "note": "the document body: served whole on single-doc GET, deliberately stripped from list responses unless `?include=content`, locale-swapped by resolveDocLocale. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `rest-server.ts:3007` had rotted onto a docblock about `enableProjectScoping`, and `doc.zod.ts:120` onto the middle of the `tags` docblock — the sentence \"the consumer already existed, so this is the enforce half of enforce-or-remove\", which is about `tags`, cited as evidence for `content`. HONEST RESIDUAL on the rest-server leg: the `?include=content` strip is inline in a ~2,800-line route registrar with no enclosing named helper, so the anchor is the registrar itself. That is coarse, and it is stated rather than dressed up — the narrow half of this entry is the `doc.zod.ts` anchor, which is exact. Re-closed by hand against 93ea19bca." }, "order": { diff --git a/packages/spec/liveness/metadata_endpoints.json b/packages/spec/liveness/metadata_endpoints.json index 807be041856..37806ac911e 100644 --- a/packages/spec/liveness/metadata_endpoints.json +++ b/packages/spec/liveness/metadata_endpoints.json @@ -14,7 +14,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.enableCache && p.getMetaItemCached ...)` selects the cached read path; the uncached branch is the default when it is false)", + "evidence": "packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes (`if (metadata.enableCache && p.getMetaItemCached ...)` selects the cached read path; the uncached branch is the default when it is false)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "The key selects between two real read paths, so authoring it changes behaviour rather than only a header. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, @@ -54,7 +54,7 @@ "status": "live", "verifiedAt": "2026-09-02", "evidenceScope": "in-repo", - "evidence": "packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`if (metadata.endpoints.item !== false)` gates the per-item read block, and `registerPerItemRoute` \u2014 the same switch, spelled as a call \u2014 gates the per-item PUT/DELETE and the history family)", + "evidence": "packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes (`if (metadata.endpoints.item !== false)` gates the per-item read block); also packages/rest/src/rest-server.ts#registerMetadataEndpointsInner (`registerPerItemRoute` \u2014 the same switch, spelled as a call \u2014 gates the per-item PUT/DELETE and the history family)", "producer": "packages/rest/src/rest-server.ts#normalizeConfig (threads the authored value into `this.config`, which is the object every consumer below reads; the parsed sub-config's own `.default()`s supply the value when the author omits the key)", "note": "Same `!== false` shape as `types`. RADIUS WIDENED 2026-09-06 (#15854, ruled with #15542): it used to gate four READS (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and leave the per-item WRITES (`PUT` / `DELETE /:type/:name`) and the history family (`history`, `audit`, `diff`, `published`, `publish`, `rollback`) answering to `api.enableMetadata` alone \u2014 the converse of the `items` mismatch: an operator who closed the per-item surface left its writes mounted. It now gates the whole per-item face, twelve mounts, which is what the switch name has always promised. `GET /meta/object/:name/state/:field` is deliberately NOT part of that face (object FSM read, addressed by object name) and stays under `api.enableMetadata` alone. REACHABILITY (#15543, measured 2026-09-08 on origin/main 8ccf7a1df): embedder-only. Written only by a host that constructs a RestServerConfig; no shipped boot path authors it, so a CLI-started deployment always gets the schema default. See this file's _note REACHABILITY paragraph. Liveness and verifiedAt are unaffected: `live` answers who READS this key, this row answers who can SET it." }, diff --git a/scripts/adr-anchors/packages__rest__src__meta-item-read-routes.ts.json b/scripts/adr-anchors/packages__rest__src__meta-item-read-routes.ts.json new file mode 100644 index 00000000000..0478a2f396e --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-item-read-routes.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/rest/src/meta-item-read-routes.ts", + "adrs": [ + "ADR-0106", + "ADR-0046" + ], + "invariant": "The `/meta` per-item read doors, moved here whole from `rest-server.ts` (#23050 slice 13b): the reverse reference sweep (`GET {metaPath}/:type/:name/references`), the layered read (`/layers`), the book tree (`GET {metaPath}/book/:name/tree`) and the item read (`GET {metaPath}/:type/:name`), mounted under the per-item switch and inside the umbrella's guarded registration. ADR-0106 — every exit that serves an object schema resolves the caller's field-visibility posture once, before the fetch, and serves fetch → mask → send through it: the layered read and the item read's layered, cached and uncached branches alike. The shared cache keeps one full schema per item and varies per caller only by that projection and by the caller's visibility fingerprint folded into the ETag, so one caller's 304 never answers another; a posture that cannot be determined is served private and no-store, never under a shared validator. ADR-0046 — the book tree is answered whole by the shared book-tree answer, behind the docs-audience gate, so a book reaches only a caller its audience admits. A schema exit that skips the projection, a validator that ignores the caller's visibility, or a book tree served around the audience gate is the drift these records forbid; the bodies' own comments carry each citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index e838b5eeef3..7811c41313d 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -413,7 +413,15 @@ const MODULES = { // go through `handleRouteError`), so this count does not move and that module // declares 0 below; the sum is unchanged // (8 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 + 0 = 39). - stringError: 8, + // + // 8 → 7 (#23050 slice 13b): NOT progress — the same MOVE as slices 1–3, 8 and + // 10–12. The fourth `/meta` group, the per-item reads, left this file for + // `meta-item-read-routes.ts` with its one literal string `error` body (the + // `/layers` door's 501 `NOT_IMPLEMENTED`, `'Layered metadata view not + // supported by protocol implementation'`), declared below at the count it had + // here; the sum is unchanged + // (7 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 + 3 + 0 + 1 = 39). + stringError: 7, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared // `respondError` helper, banking that progress per the ratchet's own rule. @@ -520,7 +528,12 @@ const MODULES = { // `{ code, error }` body either, so this count does not move and // `meta-collection-routes.ts` declares 0 below; the sum is unchanged // (4 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 + 0 = 54). - siblingCode: 4, + // + // 4 → 3 (#23050 slice 13b): NOT progress — the same MOVE as `stringError` + // above. That same `{ error, code }` body left with the per-item reads for + // `meta-item-read-routes.ts`, declared below; the sum is unchanged + // (3 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 + 3 + 0 + 1 = 54). + siblingCode: 3, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -788,6 +801,24 @@ const MODULES = { stringError: 0, siblingCode: 0, }, + + // [#23050 slice 13b] The fourth `/meta` group of + // `RestServer#registerMetadataEndpointsInner`' body — the per-item reads + // (`/references`, `/layers`, the book tree and the item read) — moved out of + // `rest-server.ts` on exactly slice 1's terms above: a MOVE, no wire answer + // changed, so it inherits `dialectOnly` and the two dialect facts, and no + // write-site count. Both counts were measured on the moved file and equal what + // left `rest-server.ts` in the same PR (stringError 8 → 7, siblingCode 4 → 3: + // the `/layers` door's one 501 body), so each sum is unchanged. Both only ever + // tick down. Discovered by the `*-routes.ts` convention and declared in the same + // PR that created the module, so the gap never exists. + 'packages/rest/src/meta-item-read-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 13b and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 1, + siblingCode: 1, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */ From d00b152fe30becd2d8f5b9ab6db8c50f5d5a3bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:35:34 +0000 Subject: [PATCH 4/5] chore(changeset): rest and spec patches for the /meta per-item read move Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../23050-s13b-meta-item-read-liveness-citations.md | 12 ++++++++++++ .changeset/23050-s13b-meta-item-read-routes-move.md | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .changeset/23050-s13b-meta-item-read-liveness-citations.md create mode 100644 .changeset/23050-s13b-meta-item-read-routes-move.md diff --git a/.changeset/23050-s13b-meta-item-read-liveness-citations.md b/.changeset/23050-s13b-meta-item-read-liveness-citations.md new file mode 100644 index 00000000000..7abf014e1cf --- /dev/null +++ b/.changeset/23050-s13b-meta-item-read-liveness-citations.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +The liveness ledger's `/meta` cache-flag, per-item switch, doc and book rows cite the code that reads their keys where it now lives + +Clause-②: no (only the rows' evidence paths and notes move and every status stays `live`: `MetadataEndpointsConfigSchema`, `DocSchema` and `BookSchema` accept exactly what they accepted before, and no state count moves) + +- `liveness/metadata_endpoints.json`: the `enableCache` row and the `if (metadata.endpoints.item !== false)` half of the `endpoints.item` row cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`. `@objectstack/rest` moved the per-item reads to `packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes` with no behaviour change, so those references follow them. The `registerPerItemRoute` half of the `endpoints.item` row keeps its citation: that call still lives in the inner method's head. +- `liveness/doc.json`: the `content` row's single-item branch, and the `_note`'s book-tree and single-item branches, cite the new module. The `doc` list branch keeps its citation. +- `liveness/book.json`: the `_note` names the new module for the `book/:name/tree` branch. +- The quoted code, the producers, the verdicts and the row notes are unchanged. diff --git a/.changeset/23050-s13b-meta-item-read-routes-move.md b/.changeset/23050-s13b-meta-item-read-routes-move.md new file mode 100644 index 00000000000..0fa2411fc64 --- /dev/null +++ b/.changeset/23050-s13b-meta-item-read-routes-move.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': patch +--- + +The `/meta` per-item read routes live in their own module, with no route, handler or answer changed + +Clause-②: no (a move: the four routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + +- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statement that registers `GET {metaPath}/:type/:name/references`, `GET {metaPath}/:type/:name/layers`, `GET {metaPath}/book/:name/tree` and `GET {metaPath}/:type/:name` moved, re-indented, to `packages/rest/src/meta-item-read-routes.ts`, and the method calls it at its old position, inside the umbrella's guarded registration. The `metadata.endpoints.item` switch gates the same mounts. +- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. From 7b562222de7a75838cff09afcc679e2ef0868b90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:35:34 +0000 Subject: [PATCH 5/5] chore(adr-anchors): anchor meta-collection-routes.ts to its ADRs The 13a move took seven ADR decision-letter citations out of the anchored rest-server.ts into the unanchored meta-collection-routes.ts, so check-adr-anchors read 1783 -> 1776 with the gate green: the letters of those citations stopped being checked. This anchor, the shape slices 4, 6, 7 and 11 used, restores the reading to 1783. Its invariant adds no letter citation of its own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../packages__rest__src__meta-collection-routes.ts.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 scripts/adr-anchors/packages__rest__src__meta-collection-routes.ts.json diff --git a/scripts/adr-anchors/packages__rest__src__meta-collection-routes.ts.json b/scripts/adr-anchors/packages__rest__src__meta-collection-routes.ts.json new file mode 100644 index 00000000000..c7d4108b68f --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-collection-routes.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/rest/src/meta-collection-routes.ts", + "adrs": [ + "ADR-0131", + "ADR-0106" + ], + "invariant": "The `/meta` collection doors, moved here whole from `rest-server.ts` (#23050 slice 13a): the type list (`GET {metaPath}` and `GET {metaPath}/types`), the whole-store maintenance family (`GET {metaPath}/diagnostics`, `GET {metaPath}/_drafts` and `POST {metaPath}/_migrate-stored`) and the per-type list (`GET {metaPath}/:type`), mounted inside the umbrella's guarded registration. ADR-0131 — no door here reads or writes an organization's partition: the diagnostics sweep, the drafts list and the per-type list read environment → code, the partition every `/meta` write lands in, and the install-wide stored-metadata rewrite asks the walled refusal after its capability gate and before the protocol is resolved or a row is read. ADR-0106 — the pending-drafts list is an authoring surface gated per caller, because a pending object draft carries its full field map, and the per-type list serves an undetermined field visibility private and no-store. A collection door that reads an organization's partition, a rewrite that runs past the walled refusal, or a drafts list served to a caller who may not author is the drift these records forbid; the bodies' own comments carry each citation." +}