From dd1f1c8906edc0b7c6cc5f84c08d2496377c2f9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:32:20 +0000 Subject: [PATCH 1/3] refactor(metadata-protocol): move the metadata item reads and the layered reads through the protocol host getMetaItem, getMetaItemForExecution and readMetaItem move to protocol-meta-item-reads.ts; packagelessLayeredReadRefusal and getMetaItemLayered move to protocol-meta-layered-reads.ts. Each body is the method's own, with this.x read as host.x (73 sites); each method stays on the class as a stub. protocol.ts drops the import names only the moved bodies read, and the two import comments they carried move with them. Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .../metadata-protocol/src/protocol-host.ts | 22 + .../src/protocol-meta-item-reads.ts | 756 +++++++++++ .../src/protocol-meta-layered-reads.ts | 574 ++++++++ packages/metadata-protocol/src/protocol.ts | 1196 +---------------- 4 files changed, 1389 insertions(+), 1159 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol-meta-item-reads.ts create mode 100644 packages/metadata-protocol/src/protocol-meta-layered-reads.ts diff --git a/packages/metadata-protocol/src/protocol-host.ts b/packages/metadata-protocol/src/protocol-host.ts index 1fe7b7363fd..438bf02166a 100644 --- a/packages/metadata-protocol/src/protocol-host.ts +++ b/packages/metadata-protocol/src/protocol-host.ts @@ -59,31 +59,53 @@ export interface ProtocolHost { readonly convertStoredItem: ObjectStackProtocolImplementation['convertStoredItem']; readonly declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow']; readonly disabledActionsOf: ObjectStackProtocolImplementation['disabledActionsOf']; + readonly draftPublishVerdict: ObjectStackProtocolImplementation['draftPublishVerdict']; readonly engine: ObjectStackProtocolImplementation['engine']; readonly environmentId: ObjectStackProtocolImplementation['environmentId']; readonly expandStoredViewContainers: ObjectStackProtocolImplementation['expandStoredViewContainers']; readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows']; + readonly findPairAddedMemberRow: ObjectStackProtocolImplementation['findPairAddedMemberRow']; + readonly findServedOverlayRow: ObjectStackProtocolImplementation['findServedOverlayRow']; readonly flowRegistrationsOf: ObjectStackProtocolImplementation['flowRegistrationsOf']; readonly foldObjectExtendersFromRegistry: ObjectStackProtocolImplementation['foldObjectExtendersFromRegistry']; readonly getServicesRegistry: ObjectStackProtocolImplementation['getServicesRegistry']; readonly governServedObject: ObjectStackProtocolImplementation['governServedObject']; readonly hydrateOverlayIntoRegistry: ObjectStackProtocolImplementation['hydrateOverlayIntoRegistry']; + readonly isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked']; readonly isStoredEntryOfDeclinedName: ObjectStackProtocolImplementation['isStoredEntryOfDeclinedName']; + readonly isStoredFlowEntryOfShippedName: ObjectStackProtocolImplementation['isStoredFlowEntryOfShippedName']; readonly isUnservedSealedOverlay: ObjectStackProtocolImplementation['isUnservedSealedOverlay']; readonly isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; readonly lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem']; readonly namesWithOwnStoredRow: ObjectStackProtocolImplementation['namesWithOwnStoredRow']; + readonly overlayLockLayerAt: ObjectStackProtocolImplementation['overlayLockLayerAt']; readonly overrideWriteVerdict: ObjectStackProtocolImplementation['overrideWriteVerdict']; readonly packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner']; readonly packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; + readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress']; + readonly packagelessReadAddress: ObjectStackProtocolImplementation['packagelessReadAddress']; readonly readActiveOverlayRows: ObjectStackProtocolImplementation['readActiveOverlayRows']; readonly readFlattenedMetaItems: ObjectStackProtocolImplementation['readFlattenedMetaItems']; + readonly readItemActiveRows: ObjectStackProtocolImplementation['readItemActiveRows']; + readonly readItemFromMetadataService: ObjectStackProtocolImplementation['readItemFromMetadataService']; + readonly readMetaItemFace: ObjectStackProtocolImplementation['readMetaItemFace']; + readonly readVersionToken: ObjectStackProtocolImplementation['readVersionToken']; + readonly registryMemberServedAt: ObjectStackProtocolImplementation['registryMemberServedAt']; + readonly resolveAppPackageIdKey: ObjectStackProtocolImplementation['resolveAppPackageIdKey']; + readonly resolveCascadeDeleteObjects: ObjectStackProtocolImplementation['resolveCascadeDeleteObjects']; + readonly resolveDisabledActionsReader: ObjectStackProtocolImplementation['resolveDisabledActionsReader']; + readonly resolveFlowRegistrationReader: ObjectStackProtocolImplementation['resolveFlowRegistrationReader']; + readonly resolveRowlessExpandedView: ObjectStackProtocolImplementation['resolveRowlessExpandedView']; + readonly resolveWritePackageScope: ObjectStackProtocolImplementation['resolveWritePackageScope']; readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned']; + readonly servedLockState: ObjectStackProtocolImplementation['servedLockState']; readonly shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf']; readonly shippingPackagesOf: ObjectStackProtocolImplementation['shippingPackagesOf']; readonly shipsBundleName: ObjectStackProtocolImplementation['shipsBundleName']; readonly storedFlowBindingAgrees: ObjectStackProtocolImplementation['storedFlowBindingAgrees']; readonly storedOverlayEntries: ObjectStackProtocolImplementation['storedOverlayEntries']; + readonly strictDraftReadAddress: ObjectStackProtocolImplementation['strictDraftReadAddress']; + readonly throwMetadataServiceUnavailable: ObjectStackProtocolImplementation['throwMetadataServiceUnavailable']; readonly uninstalledNamedBaseRefusal: ObjectStackProtocolImplementation['uninstalledNamedBaseRefusal']; } diff --git a/packages/metadata-protocol/src/protocol-meta-item-reads.ts b/packages/metadata-protocol/src/protocol-meta-item-reads.ts new file mode 100644 index 00000000000..313eff69b44 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-meta-item-reads.ts @@ -0,0 +1,756 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E2 cut 1b] The item reads of `ObjectStackProtocolImplementation`: + * `getMetaItem` and `getMetaItemForExecution` (one item, as the `/meta` item + * route and the runtime read it) and `readMetaItem`, the private read both + * share. + * + * A MOVE, not a redesign. Each function below is the body of the class method + * of the same name as it stood, re-indented one level, with `this.` + * read as `host.` and nothing else changed + * (`node scripts/host-split.mjs check protocol-meta-item-reads.ts` + * proves it). Each method stays on the class with its docblock and signature, + * which the published `.d.ts` prints, and hands its body the host + * (`protocol-host.ts`). + * + * What a body reads from elsewhere, it imports from where `protocol.ts` + * imports it. Nothing re-exports this module, so the published surface does + * not move. + */ + +import { itemDiscriminator } from '@objectstack/metadata-core'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +// [#24395] The draft row's per-key provenance, read back for the strict draft read. +import { storedDraftChanges } from './draft-changes.js'; +import { type StoredOverlayRow, resolveItemLock, storedRowDocument } from './item-lock.js'; +import { + type MetadataDiagnostics, + computeViewReferenceDiagnostics, + decorateMetadataItem, + withFormLayoutWarnings, + withPublishVerdict, +} from './metadata-diagnostics.js'; +import { noPendingDraftError } from './protocol-authoring-refusals.js'; +import { + bundleMemberDiscriminator, + memberOption, + resolveBundleMemberSelector, +} from './protocol-bundle-members.js'; +import { packagelessContentPackage } from './protocol-environment-rows.js'; +import type { ProtocolHost } from './protocol-host.js'; +import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js'; +import { + bundleMemberOf, + envelopePackageId, + mergeArtifactProtection, + withRowBinding, +} from './protocol-package-bindings.js'; +import type { ServedOverrideRow } from './served-override.js'; + +/** The body of `ObjectStackProtocolImplementation#getMetaItem`; the method's docblock there is its contract. */ +export async function getMetaItem(host: Pick, request: { + type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, + /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string, + }) { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. Folded + // HERE, at each face's boundary, as {@link getMetaItems} folds. + return host.readMetaItemFace(canonicalizeMetaRequestType(request), 'served'); +} + +/** The body of `ObjectStackProtocolImplementation#getMetaItemForExecution`; the method's docblock there is its contract. */ +export async function getMetaItemForExecution(host: Pick, request: { + type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, + /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string, + }) { + // The same #4432 fold {@link getMetaItem} applies, for the same reason. + return host.readMetaItemFace(canonicalizeMetaRequestType(request), 'execution'); +} + +/** The body of `ObjectStackProtocolImplementation#readMetaItem`; the method's docblock there is its contract. */ +export async function readMetaItem(host: Pick, request: { + type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, + member?: string, + }, opts: { face?: 'served' | 'execution' } = {}) { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. + request = canonicalizeMetaRequestType(request); + // [#23183] ADR-0048 §3.1: the app read keyed by PACKAGE ID, resolved + // ONCE, here, to the package's one app read by its own name in that + // package, so every step below (the stored row, the lock, the version + // token) reads the app exactly as its by-name read does. See + // {@link resolveAppPackageIdKey}. + request = await host.resolveAppPackageIdKey(request); + // [#22877] Ruling 6116165299 (Q2 i): ONE member of a bundled type is + // addressed by name, the selector's or else the canonical one. Every + // layer below answers that member or nothing — the stored row, the + // code layer's member and the registry's. + const member = resolveBundleMemberSelector(request.type, request.member); + const wantedMember = bundleMemberDiscriminator(request.type, member); + const isWantedMember = (candidate: unknown): boolean => + wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember; + let item: unknown; + // [ADR-0131 D6] Environment → code: the environment's stored row, + // else the code packages' definition. No organization's row is read — + // a legacy organization-scoped row is refused at boot + // (the v18 boot refusal, ADR-0131 D10 item 5) and never served. + // Studio's editor opens a draft buffer with `state: 'draft'`; + // runtime loaders omit it and get the live published row. + const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; + // [#24535] The serving decorations at this read's exits are the ONE + // thing the two API faces do not share. The served face (and an + // authoring caller) decorates as before; the execution face hands the + // governed item back as resolved ({@link getMetaItemForExecution}). + // Every read of `execution` below is one of those exits. + const execution = opts.face === 'execution'; + const serveItem = (resolved: T): T => (execution ? resolved : decorateMetadataItem(request.type, resolved)); + // [#29196] A served VIEW's `_diagnostics` also carries the lint's + // `form-field-unknown` warnings ({@link withFormLayoutWarnings}), judged + // against the object the registry serves for each name the view spells. + // Applied at every served exit of this read, the draft ones included; a + // warning, so `valid` is left as the schema answered it. The execution + // face serves no `_diagnostics`. + const serveFormLayout = (served: T): Promise => (execution + ? Promise.resolve(served) + : withFormLayoutWarnings(request.type, served, (name) => host.engine?.registry?.getObject?.(name))); + // [#26707] A served DRAFT is decorated as above and then told what its + // publish would refuse: the runtime gate's verdict on the stored draft + // body ({@link draftPublishVerdict}), folded into `_diagnostics` + // ({@link withPublishVerdict}). Judged on the row as stored — the body + // the promotion judges — never on the governed, converted or redacted + // copy this read serves. The execution face serves no `_diagnostics`. + const serveDraft = async (resolved: T, row: { metadata?: unknown; package_id?: string | null }): Promise => { + const served = await serveFormLayout(serveItem(resolved)); + if (execution || !served || typeof served !== 'object') return served; + const issues = await host.draftPublishVerdict({ + type: request.type, + name: request.name, + body: storedRowDocument(row), + packageId: row.package_id ?? null, + }); + const before = (served as { _diagnostics?: MetadataDiagnostics })._diagnostics; + const after = withPublishVerdict(request.type, request.name, before, issues); + return after === before ? served : { ...served, _diagnostics: after }; + }; + // ── [#20946, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ── + // + // `flow` is Regime C: the packaged base is locked, "⛔ Never silent + // override, never an overlay read path". Since #20913 the flattened view + // ({@link readFlattenedMetaItems}, both faces) serves a flow name the + // loader's set holds from the loader's own entries alone, with two + // predicates. This read applies the SAME two, and ⛔ no third precedence + // path of its own: + // • the stored-row half, {@link isShippedFlowName}, judged by NAME — + // step 1 below does not adopt the stored row of such a name, whatever + // its package binding or the stamps its own body carries; + // • the registry half, {@link isStoredFlowEntryOfShippedName} — step 3 + // does not serve the registry's bare copy of that row (the hydrated + // tenant row `getItem` answers first); the loader's entry is served. + // Adopted, the row was served with the artifact's protection envelope + // grafted over it (the merge at the end of this method): a stored body + // answered as the package's definition by name, while the list answered + // the loader's body for the same name. + // + // Scoped to the ACTIVE read. A draft is answered as a draft (the strict + // `state: 'draft'` read and the `previewDrafts` arm), never under the + // artifact's envelope, and the list's own preview arm is equally + // unfiltered. Organization-scoped rows never reach this read + // (ADR-0131 D6). What becomes of the stored rows themselves (keep, refuse, migrate) is + // not decided here. + // + // ── [#21922, ADR-0062 D4, ADR-0126 §3] A code-defined DATASOURCE name ── + // + // The stored-row half covers a second name class, through the same + // predicate ({@link declinesStoredRow}): a datasource name the host + // registers from code ({@link isDeclaredCodeDatasource}). "Code wins on + // collision": its code definition is the MetadataService's in-memory + // registration, which step 2 serves, and a stored row under the name is + // residue, never a layer of it. Adopted, the row was served here while + // the list, the admin door and the boot restore all served the code + // definition. This read needs no registry half for it: step 2 answers + // before step 3's bare registry slot, which is where a hydrated copy of + // the row sits. The row is still FOUND (`storedRowServed` below), so the + // `/meta` DELETE that removes it as repair stays `deletable`. A draft + // is answered as a draft, as above. + const storedRowDeclined = readState === 'active' && host.declinesStoredRow(request.type, request.name); + + // [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The package whose stored + // content a read that names no package serves: the address a save or + // a delete naming none writes ({@link packagelessAddress}), read from + // the item's ACTIVE rows at both states, as the draft save inherits + // its package from them (#11087). For an item bound to exactly one + // package, the read then serves what the read naming that package + // serves (its own row, else the package-less one); otherwise any row, + // the best-effort first match ADR-0048 §3.3 states. + // + // Before, it took the first row of any package. A save naming the + // package beside the item's package-unbound row mints the package's + // row and leaves the unbound one stored first, so this read kept + // serving the unbound body while the package-less save wrote the + // package's row, the package-less delete removed it, the list served + // it, and this read's own `version` named it (#23165). The package-less + // row is not converted or removed: ADR-0070 keeps it as a read-side + // detail and a migration source, and it still stands in for every + // package with no row of its own (ADR-0048 prefer-local). + // + // Content only: the lock is selected from the item's rows at the + // address the `_lock` gate judges a package-less save at, below. + // + // [#23715, ADR-0048, ADR-0070 TL;DR 1] The strict draft read that names + // no package takes its content from the draft its `version` names + // ({@link strictDraftReadAddress}), the address the draft save, the + // per-item publish and the draft discard naming none act on. The + // active rows above say nothing about an item never published into a + // package, so with drafts `[package-unbound, Q]` this read served the + // first draft row the store returned under the package-unbound + // draft's token, and a save pinned to that token wrote a row other + // than the one read (#8907). The draft-overlay preview below shares + // the address: it serves the draft the strict read serves. + const draftContentAddress = !request.packageId && (readState === 'draft' || request.previewDrafts) + ? await host.strictDraftReadAddress(request.type, request.name, member) + : undefined; + // [#24371] The item's ACTIVE rows, both spellings, read ONCE + // ({@link readItemActiveRows}): the package-less binding, the served + // row and the lock below each select from them by the `where` it would + // have sent the store. They were five serial reads of the same rows, + // on every `/data` request. [#10757 leg 6] On the API faces (served and, + // [#24535], execution) the read goes through the overlay cache; an + // authoring read is never kept. + let activeRows: StoredOverlayRow[] | undefined; + if (readState === 'active') { + try { + activeRows = await host.readItemActiveRows(request.type, request.name, opts.face !== undefined); + } catch (error) { + // [#5532] Only an unprovisioned store holds no row; any other + // failure is a read that did not happen, never "no row". + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + activeRows = []; + } + } + // The package-less binding ({@link packagelessReadAddress}'s), selected + // from those rows; the `version` below ({@link readVersionToken}) + // takes the same binding instead of reading it again. + const packagelessAddressed = readState === 'active' && !request.packageId + ? await host.packagelessAddress( + PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member, 'active', activeRows, + ) + : undefined; + const packagelessBinding = packagelessAddressed?.packageId; + // [#28238] The content, though, is {@link packagelessContentPackage}'s: + // the code package that ships the name is a candidate too, so a row + // another package stored under the name is not served for it. + const contentPackageId = request.packageId || (packagelessAddressed + ? packagelessContentPackage(packagelessAddressed, host.shippedArtifactsOf(request.type, request.name)) + : undefined); + + // ADR-0033 draft-overlay preview (non-strict): when the caller opts in + // (admin-gated upstream), prefer a `state='draft'` row if one exists, else + // fall back to the active read below. This differs from the strict + // `state:'draft'` mode, which 404s (`no_draft`) when no draft exists — the + // render path must degrade to the published value, not error. The draft + // item is tagged `_draft:true` so the UI can badge it. + if (request.previewDrafts && readState !== 'draft') { + try { + // [#21716] The served-row resolution, on the draft partition + // (ADR-0048 prefer-local, parity with the row read below) — see + // {@link findServedOverlayRow}. + // [#28409] A preview serves the package's name, so the + // pending edit of its declared override is a candidate + // ({@link servedOverlayRowCandidates}' `servedOverride`), as + // in the list's draft-preview merge. + const servedDraft = await host.findServedOverlayRow({ + type: request.type, + name: request.name, + state: 'draft', + ...(draftContentAddress + ? { packageId: draftContentAddress.packageId } + : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), + ...memberOption(member), + servedOverride: true, + }); + let draftRec = servedDraft?.row; + // [#23715] Two or more packages and no package-unbound draft: + // the best-effort first match, as the strict read below. + if (!draftRec && draftContentAddress?.bestEffort) { + draftRec = (await host.findServedOverlayRow({ + type: request.type, name: request.name, state: 'draft', ...memberOption(member), + }))?.row; + } + if (draftRec) { + const draftItem = host.convertStoredItem( + String(draftRec.type ?? request.type), + typeof draftRec.metadata === 'string' + ? JSON.parse(draftRec.metadata) + : draftRec.metadata, + ) as any; + let previewed = draftItem; + if (previewed && typeof previewed === 'object') { + const recPkg = (draftRec as { package_id?: string | null }).package_id ?? undefined; + // [#28409] A declared override's draft previews as the + // overridden package's item, as its active row serves. + if (servedDraft?.overrideOf !== undefined && servedDraft.row === draftRec) { + previewed = { ...previewed, _packageId: servedDraft.overrideOf }; + } else previewed = withRowBinding(previewed, recPkg); // [#28673] the column's package + (previewed as any)._draft = true; + } + return { + type: request.type, + name: request.name, + item: await serveDraft(host.governServedObject(request.type, previewed), draftRec), + }; + } + } catch (error) { + // [#5532] Falling through to the active read here would answer + // "there is no draft for this item" from a read that never + // reached the table the drafts live in. + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + } + + // 1. Customization overlay lookup (sys_metadata): the environment's + // row (ADR-0131 D6/D7: {@link findEnvironmentRow}) before falling + // through to the in-memory registry / MetadataService. + // ADR-0048 prefer-local (a package id prefers that + // package's row, then the package-less one, mirroring + // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE + // served-row resolution — see {@link findServedOverlayRow}. It + // decides the CONTENT only; the lock is selected below from this + // read's address ([#21761]). + // [#21899] Whether a stored row was found — the fact the origin-gated + // repair's `deletable` reads ({@link servedLockState}). + let storedRowServed = false; + // [#24395] The served draft row's per-key provenance — read off the + // row this read serves, so it can never describe another body. + let draftChanges: ReturnType; + // [#26707] The draft row this read serves, for its publish verdict. + let servedDraftRow: { metadata?: unknown; package_id?: string | null } | undefined; + try { + // [#28409, ADR-0146 D8 item 2] The active read serves the + // package's name, so its declared override is the first candidate + // ({@link servedOverlayRowCandidates}' `servedOverride`). The + // strict draft read answers the address a draft save writes, never + // the pair's: it asks without it. + const served = await host.findServedOverlayRow({ + type: request.type, + name: request.name, + state: readState, + ...(readState === 'draft' && draftContentAddress + ? { packageId: draftContentAddress.packageId } + : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), + ...memberOption(member), + ...(readState === 'active' ? { servedOverride: true } : {}), + }, activeRows); + let record = served?.row; + // [#28802, #28390 ruling A] A member of a bundle the address's + // package ships and does not ship at that member: the pair's own + // added member is the address's, served from the pair's row under + // the pair's binding (a dependent's member never is). + if (!record && readState === 'active' && wantedMember !== undefined && typeof contentPackageId === 'string') { + record = await host.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember, activeRows); + } + // [#23715] Two or more packages and no package-unbound draft: the + // best-effort first match ADR-0048 §3.3 states, as before, served + // with `version: null` (no row at the address the save naming none + // resolves, and that save is refused there). + if (!record && readState === 'draft' && draftContentAddress?.bestEffort) { + record = (await host.findServedOverlayRow({ + type: request.type, + name: request.name, + state: readState, + ...memberOption(member), + }))?.row; + } + storedRowServed = record !== undefined && record !== null; + // [#20946, #21922] The stored-row half — see `storedRowDeclined` above. + // [#28412, ADR-0146 D9 item 1] Judged again on the row itself when + // the name declines: a shipped flow name's declared override, the + // row {@link findServedOverlayRow} serves first, is the packaged + // flow, served as the list's slot serves it ({@link declinesStoredRow}). + if (record && !(storedRowDeclined && host.declinesStoredRow(request.type, request.name, record as ServedOverrideRow))) { + item = host.convertStoredItem( + String(record.type ?? request.type), + storedRowDocument(record), + ); + // Surface the persisted software-package binding (parity with + // the list path in getMetaItems) so provenance/UI can read it. + const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; + if (served?.overrideOf !== undefined && served.row === record && item && typeof item === 'object') { + // [#28409] A declared override is served as the package's + // item it overrides, as the list's slot serves it: under + // that package's provenance, so the envelope below is the + // overridden package's artifact's ({@link envelopePackageId}). + item = { ...(item as Record), _packageId: served.overrideOf }; + } else { + // [#28673] The column's package, and only the column's ({@link withRowBinding}). + item = withRowBinding(item, recPkg); + } + if (readState === 'draft') { + draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes); + servedDraftRow = record as { metadata?: unknown; package_id?: string | null }; + } + } + } catch (error) { + // [#5532] THE site this issue was raised on. Falling through to the + // registry / MetadataService with `item` still `undefined` is what + // let a storage outage arrive at the client as `not found` (active + // read) or `NO_DRAFT` (draft read) — both of them claims about + // authorship, made from a read that never happened. + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + + // Draft reads stop here — they intentionally do NOT fall through + // to the runtime registry / MetadataService (which only know + // about published values). When the draft row is missing we + // throw `no_draft` (HTTP 404) so the REST contract is identical + // to `POST /publish` on an empty slot: clients use a single + // status code to decide "no pending edit" without sniffing + // envelope shape. See ADR-0005 §draft-lifecycle. + if (readState === 'draft') { + if (item === undefined) throw noPendingDraftError(request.type, request.name); + const governedDraft = host.governServedObject(request.type, item); + return { + type: request.type, + name: request.name, + item: servedDraftRow ? await serveDraft(governedDraft, servedDraftRow) : await serveFormLayout(serveItem(governedDraft)), + // [#22114] The draft row's version token — the one a draft save + // (`?mode=draft`) at this address accepts as `If-Match`. + // [#22877] The member's own row's, as a save of that member's + // body addresses it. + version: await host.readVersionToken({ ...request, member }, 'draft'), + // [#24395] `GetMetaItemResponseSchema.draftChanges`: present + // only when the draft row's saves recorded at least one entry + // — ⛔ never `[]` for a row that recorded none. + ...(draftChanges ? { draftChanges } : {}), + }; + } + + // [#21761] The item-lock resolution's `overlay` layer, selected from + // this read's ADDRESS ({@link overlayLockLayerAt}) by the selection + // the `_lock` gate makes: the strictest lock among the item's stored + // rows in scope, whichever package each is bound to. Not the row served + // above, which is the address's preferred CONTENT (ADR-0048), and read + // whether or not that row is adopted (a shipped flow name, #20946, and a + // code-defined datasource name, #21922, do not serve their stored row, + // and the gate binds it all the same). + let overlayLockLayer: unknown; + try { + overlayLockLayer = await host.overlayLockLayerAt({ + type: request.type, + name: request.name, + packageId: request.packageId, + }, { otherSpelling: true }, activeRows); + } catch (error) { + // [#5532] The same rule as the row read above: a lock read that did + // not happen is not an unlocked item. + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + + // [#8027] An OBJECT's overlay row is a BASE LAYER, not a resolved + // schema. ADR-0029 D9.2 defines the resolution as `overlay ?? own` with + // the `extend` contributors folded ON — which is what + // `SchemaRegistry.resolveObject` does for an overlay it knows about, and + // what step 2 below already does (#7556) for the MetadataService copy. + // Step 1 was the one adopter that served its layer verbatim, so a single + // customisation row — an admin renaming the object's label in Studio — + // silently dropped every extension-contributed field from this read, and + // therefore from every writable form derived from it, while the data API + // kept accepting and persisting those same fields. + // + // Placed AFTER the draft return on purpose: a draft is a pending edit of + // the base layer, and folding it would show an author extension fields + // inside the document they are editing and about to PUT back. Drafts were + // never folded (before #7556 nothing was), so this leaves that asymmetry + // exactly where it already was rather than widening this fix into it. + // + // No-op for every type but `object`, for an object nothing extends, and + // for an item with no overlay row (`item` is still undefined here, and + // step 2 / step 3 fold their own sources). + if (item !== undefined) { + item = host.foldObjectExtendersFromRegistry(request.type, request.name, item); + } + + // 1b. [#21442] A view name with no stored row of its own that a stored + // view container in this caller's scope EXPANDS — the item the + // object door (`GET /meta/view?object=`) lists under this name, + // resolved by {@link resolveRowlessExpandedView} through the list + // read's own selection and expansion. Placed before steps 2 and 3 + // because the list read's expansion wins over the MetadataService + // and registry items of the same name too; there, a name a package + // ships (`.default` under a tenant overlay of that package's + // container) answered the packaged row while the list served the + // expansion, and on an unscoped kernel a hydrated registry copy + // answered only for an environment-wide container. ⛔ No + // kernel-specific branch: every kernel answers here. + if (item === undefined) { + const expanded = await host.resolveRowlessExpandedView(request); + if (expanded !== undefined) item = expanded.item; + } + + // 2. MetadataService (runtime-registered items: HMR-updated view/page/ + // dashboard/agent/tool, plus FilesystemLoader-sourced items). This + // is consulted BEFORE the in-memory SchemaRegistry because the + // registry is a boot-time cache populated by `loadMetadataFromService` + // and is NOT invalidated on `MetadataManager.register()` (which is + // how the CLI dev watcher pushes recompiled metadata into the + // running server). Without this ordering, edits to `*.view.ts` + // source files appear to take effect (MetadataManager learns the + // new value) but reads continue to return the stale registry copy. + // [#5840] `serviceDegraded` survives past the registry step below on + // purpose — see the branch that reads it after step 3 for why the + // verdict cannot be acted on here. + let serviceDegraded: { degraded: boolean; errors: string[] } | undefined; + if (item === undefined) { + try { + // Threads the caller's package id (ADR-0048) so a single-item + // fetch is package-scoped: when two installed packages ship the + // same type/name, the facade prefers the requester's own item. + const fromService = await host.readItemFromMetadataService( + request.type, + request.name, + request.packageId, + ); + if (fromService.data !== undefined && fromService.data !== null) { + // [#22877] …when it is the member this read addresses. The + // service keys a type's items by name alone, so for a + // bundle it holds whichever member it registered last, and + // that one answered every member's read. + // [#7556] A layer, not a resolved schema — see + // {@link foldObjectExtendersFromRegistry}. No-op for every + // type but `object`, and for an object nothing extends. + if (isWantedMember(fromService.data)) { + item = host.foldObjectExtendersFromRegistry( + request.type, request.name, fromService.data, + ); + } + } else if (fromService.degraded) { + serviceDegraded = fromService; + } + } catch { + // MetadataService not available — fall through + } + } + + // 3. In-memory SchemaRegistry (artifact-loaded out-of-box values, and + // items that bypass MetadataService — e.g. some object-schema + // extension chains registered by AppPlugin directly). + // Both control-plane (unscoped) and project kernels consult the + // registry. The previous guard that skipped the registry for + // project kernels was meant to prevent cross-project leakage at + // the LIST level — but for a single-item lookup the kernel's own + // `engine.registry` is project-local (each ObjectQL instance has + // its own SchemaRegistry), so reading from it is safe and + // necessary. Without this, project-kernel callers of + // `GET /api/v1/meta/object/` 404 even though the object is + // registered and visible via the list endpoint. + // [#22877] A bundled type's name addresses ONE member (ruling A, Q2 i): + // the selector's, else the CANONICAL one. Step 1 read only that + // member's stored row; when there is none, the member is the code + // layer's — the artifact that ships it — and then the registry's, asked + // for that member. The registry's bare-name read answers the canonical + // member wherever it sits (its own [#22877] note), and still answers a + // bundle that ships no canonical member at all; a named member that no + // layer holds answers nothing, so the door answers it as absent. + if (item === undefined && wantedMember !== undefined) { + const artifact = host.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember); + if (artifact !== undefined && isWantedMember(artifact)) item = artifact; + } + if (item === undefined) { + item = host.engine.registry.getItem(request.type, request.name, request.packageId, member); + if (item === undefined) { + const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; + if (alt) item = host.engine.registry.getItem(alt, request.name, request.packageId, member); + } + // [#22877] A registry that does not narrow by member (a host + // double, an older registry) can answer another member of the + // bundle for a named one; that answer is not this member. + if (member !== undefined && item !== undefined && !isWantedMember(item)) item = undefined; + // [#20946] The registry half — see `storedRowDeclined` above. + // `getItem` answers the bare slot first, and for a shipped flow name + // that slot holds the hydrated stored row, which is not one of the + // loader's entries; the loader's entry is the one the list serves. + // [#21922] Flow-only: a code-defined datasource's definition is not + // in the registry, and step 2 has already served it. + if (host.isStoredFlowEntryOfShippedName(request.type, item)) { + item = host.lookupArtifactItem(request.type, request.name, request.packageId); + } + // [#28802, #28390 ruling A] A bundle member on a base's address + // (the package named, else the one whose content a read naming + // none serves) is one that address serves: the registry's last + // tier, the first composite match across packages (ADR-0048 §3.3), + // no longer answers a dependent's member under the base's name. + const memberAddress = request.packageId || (typeof contentPackageId === 'string' ? contentPackageId : undefined); + if (item !== undefined && wantedMember !== undefined && memberAddress !== undefined) { + item = host.registryMemberServedAt(request.type, request.name, memberAddress, item, member); + } + } + + // [#5840] The MetadataService half of the #5532 rule, and the last + // moment it can be applied. The cached-read wrapper below this method + // documents its 404 as "reaching here now means a real miss — + // `getMetaItem` throws 503 rather than answering `undefined` when the + // store could not be read". That was true of the overlay read only: a + // metadata database the LOADERS could not reach was warn-logged inside + // `MetadataManager` and arrived at step 2 as a plain `undefined`, so + // the outage was served as `404 RESOURCE_NOT_FOUND` — a claim about + // what the author declared, made from a read that never happened. + // + // Deliberately narrow, and deliberately after step 3: a registry hit is + // a real declaration, so the answer contains no false claim and is + // served exactly as before (possibly staler than the service copy — the + // pre-existing ordering trade-off, not this issue's). Only when the + // WHOLE chain resolved nothing does the degraded read change anything, + // because only then would this method answer "no such item". + if (item === undefined && serviceDegraded?.degraded) { + host.throwMetadataServiceUnavailable(serviceDegraded.errors); + } + + // Merge registered navigation contributions into a served app + // (ADR-0029 D7) — parity with the getMetaItems list path so a + // single-app fetch (GET /meta/app/) also sees the contributed + // menu entries, not just the empty group-anchor shell. + if ((request.type === 'app' || request.type === 'apps') && item) { + item = host.engine.registry.applyNavContributions(item); + } + + // ADR-0010 §3.3 — artifact-level protection (lock/packageId) always + // wins over any overlay row. The metadata service may return a + // persisted overlay copy that pre-dates the artifact's `_lock` + // declaration; we must consult the in-memory artifact registry + // directly and let its protection envelope override. + // ADR-0048 — scope the artifact lookup to the requested package so a + // same-name collision grafts the OWNING package's provenance envelope + // (`_packageId`), not whichever package registered first. + // [#22024] …and, naming no package, to the package of the item served + // above ({@link envelopePackageId}, the list's own rule), so the answer + // wears the envelope of the package whose row, expansion or item it + // serves. Before, a read naming no package took the first-registered + // package's envelope, so a name two packages ship answered one + // package's stored copy under the other package's `_packageId`. + // [#23074] …and at the served item's own bundle member, whose artifact + // alone may lend it an envelope ({@link mergeArtifactProtection}): a + // member the package does not ship is served as the environment's own. + const artifactItem = host.lookupArtifactItem( + request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item), + ); + // [#21738] The lock is the one item-lock resolution's, over the layers + // this read resolved from its address: [#21803] every installed + // package that ships the name ({@link artifactLockLayerAt}), never the + // one artifact the content above came from, and [#21761] the rows in + // scope. + const itemLock = resolveItemLock({ + artifact: host.artifactLockLayerAt({ + type: request.type, + name: request.name, + packageId: request.packageId, + }), + overlay: overlayLockLayer, + }); + // [#21761, #21803] …and the body carries that answer's lock family, so + // it states the lock the envelope below reports. [#24535] On the + // execution face `decorated` is that governed body undecorated. + let decorated = serveItem( + host.governServedObject( + request.type, + mergeArtifactProtection(request.type, item, artifactItem, itemLock), + ), + ); + // ADR-0047 — list views additionally get reference-integrity + // diagnostics (userFilters/tabs fields must exist on the source + // object, kanban groupBy must be select-like). Zod cannot see + // across documents; merge the cross-document errors into the + // same `_diagnostics` envelope. Defensive: a failed lookup must + // never break a read. [#24535] Served only: a serving decoration. + if (!execution && (request.type === 'view' || request.type === 'views') && decorated && typeof decorated === 'object') { + try { + const viewDoc = decorated as Record; + const sourceObject = viewDoc?.object + ?? viewDoc?.data?.object + ?? viewDoc?.objectName + ?? viewDoc?.list?.data?.object; + const objectDef = typeof sourceObject === 'string' + ? host.engine.registry.getObject(sourceObject) + : undefined; + if (objectDef) { + const refs = computeViewReferenceDiagnostics(viewDoc, objectDef as any); + if (!refs.valid) { + const prior = viewDoc._diagnostics; + decorated = { + ...viewDoc, + _diagnostics: { + valid: false, + errors: [ + ...(prior && prior.valid === false && Array.isArray(prior.errors) ? prior.errors : []), + ...(refs.errors ?? []), + ], + }, + } as typeof decorated; + } + } + } catch { /* reference diagnostics are best-effort */ } + } + // [#29196] …and the lint's form-layout warnings, after the reference + // merge above (which rebuilds `_diagnostics` from its errors alone). + decorated = await serveFormLayout(decorated); + // ADR-0010 — surface lock/provenance flags so Studio can render + // the correct affordances without a second round trip. [#21670] They + // report the write doors' verdicts — see {@link servedLockState}. + // [#21738] The lock is the one item-lock resolution's (above), over the + // layers this read resolved — never the served document's `_lock`. + const artifactBacked = host.isArtifactBacked(request.type, request.name); + const lockState = host.servedLockState( + request.type, request.name, decorated, artifactBacked, itemLock, storedRowServed, + ); + // [#23316] Beside `lock`, never inside `item`: the automation engine's + // verdict on this flow ({@link resolveFlowRegistrationReader}), for an + // item this read serves. The draft returns above carry none — a + // pending body is not what the engine holds. [#24535] Served only, as + // the list's `registrations` are: the execution face carries none. + const readRegistration = !execution && decorated !== undefined && decorated !== null + ? host.resolveFlowRegistrationReader(request.type) + : undefined; + // [#27842] …and, on an `object` read, the names in `item.actions` the + // activation ledger switches off ({@link resolveDisabledActionsReader}), + // beside the item on the same terms: served face only, none on the + // draft returns above, `item` untouched. + const readDisabledActions = !execution && decorated !== undefined && decorated !== null + ? host.resolveDisabledActionsReader(request.type) + : undefined; + // [#28001] …and the objects a delete of one of its records can cascade + // into ({@link resolveCascadeDeleteObjects}), on the same terms. + const cascadeDeleteObjects = !execution && decorated !== undefined && decorated !== null + ? host.resolveCascadeDeleteObjects(request.type, request.name) + : undefined; + return { + type: request.type, + name: request.name, + item: decorated, + lock: lockState.lock, + ...(lockState.lockReason !== undefined ? { lockReason: lockState.lockReason } : {}), + ...(lockState.lockSource !== undefined ? { lockSource: lockState.lockSource } : {}), + ...(lockState.lockDocsUrl !== undefined ? { lockDocsUrl: lockState.lockDocsUrl } : {}), + ...(lockState.provenance !== undefined ? { provenance: lockState.provenance } : {}), + ...(lockState.packageId !== undefined ? { packageId: lockState.packageId } : {}), + ...(lockState.packageVersion !== undefined ? { packageVersion: lockState.packageVersion } : {}), + editable: lockState.editable, + deletable: lockState.deletable, + resettable: lockState.resettable, + // [#22114] The active row's version token ({@link readVersionToken}). + // No row was found at any candidate this read consults — which + // include the save's own address — so `null` needs no second read. + // ABSENT on a `previewDrafts` read: it serves a pending draft where + // one exists and the active item where none does, so no single + // lifecycle's token describes it, and it is a render path — an + // editor reads `state: 'draft'` or the plain read. + ...(request.previewDrafts + ? {} + : { + version: storedRowServed + ? await host.readVersionToken({ ...request, member }, 'active', packagelessBinding) + : null, + }), + ...(readRegistration ? { registration: readRegistration(request.name) } : {}), + ...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}), + ...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}), + }; +} diff --git a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts new file mode 100644 index 00000000000..a4fc1e05506 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts @@ -0,0 +1,574 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25467 E2 cut 1b] The layered reads of `ObjectStackProtocolImplementation`: + * `packagelessLayeredReadRefusal` (the refusal the layered route answers + * before it reads) and `getMetaItemLayered` (one item's code, overlay and + * effective layers). + * + * A MOVE, not a redesign. Each function below is the body of the class method + * of the same name as it stood, re-indented one level, with `this.` + * read as `host.` and nothing else changed + * (`node scripts/host-split.mjs check protocol-meta-layered-reads.ts` + * proves it). Each method stays on the class with its docblock and signature, + * which the published `.d.ts` prints, and hands its body the host + * (`protocol-host.ts`). + * + * What a body reads from elsewhere, it imports from where `protocol.ts` + * imports it. Nothing re-exports this module, so the published surface does + * not move. + */ + +import { + itemDiscriminator, + // [#21059] The tenant marker the hydrator writes on every stored row it + // registers (ADR-0010 `_provenance: 'org'`) — read by + // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer + // fallback so a hydrated row is never answered as the code layer. + isTenantAuthored, +} from '@objectstack/metadata-core'; +import { + type MetadataLock, + type MetadataLockSource, + type MetadataProvenance, + customizationPackageId, +} from '@objectstack/spec/kernel'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import { + type StoredOverlayRow, + resolveItemLock, + storedRowDocument, + withItemLockFamily, +} from './item-lock.js'; +import { type MetadataDiagnostics, computeMetadataDiagnostics } from './metadata-diagnostics.js'; +import { redactMetadataItem } from './metadata-redaction.js'; +import { overlayMemberWhere } from './migrations/overlay-index.js'; +import { + isObjectOfDisabledPackage, + objectPackageDisabledError, +} from './protocol-authoring-refusals.js'; +import { + bundleMemberDiscriminator, + memberOption, + resolveBundleMemberSelector, +} from './protocol-bundle-members.js'; +import type { ProtocolHost } from './protocol-host.js'; +import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js'; +import { rowMatchesOverlayWhere } from './protocol-overlay-merge.js'; +import { boundPackageOf } from './protocol-package-bindings.js'; +import type { RowlessExpandedView } from './protocol-stored-overlay-types.js'; +import { type ServedOverrideRow, isServedOverrideRowOf } from './served-override.js'; +import { packagelessAddressAmbiguityRefusal } from './sys-metadata-repository.js'; + +/** The body of `ObjectStackProtocolImplementation#packagelessLayeredReadRefusal`; the method's docblock there is its contract. */ +export async function packagelessLayeredReadRefusal(host: Pick, request: { + type: string; + name: string; + /** The bundle member the layered read addresses ({@link resolveBundleMemberSelector}); omitted, the canonical one. */ + member?: string; + }): Promise<(Error & { code: 'RESOURCE_CONFLICT'; status: 409 }) | undefined> { + const { type, name } = canonicalizeMetaRequestType(request); + const member = resolveBundleMemberSelector(type, request.member); + const address = await host.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member); + return address.candidates.length > 1 + ? packagelessAddressAmbiguityRefusal('layered read', type, name, address.candidates) + : undefined; +} + +/** The body of `ObjectStackProtocolImplementation#getMetaItemLayered`; the method's docblock there is its contract. */ +export async function getMetaItemLayered(host: Pick, request: { + type: string; + name: string; + packageId?: string; + /** [#26699] The bundle member whose layers to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; + }): Promise<{ + type: string; + name: string; + code: unknown | null; + overlay: unknown | null; + overlayScope: 'env' | null; + effective: unknown | null; + /** + * Load-time validation result for the effective payload — same + * shape attached to getMetaItems/getMetaItem by + * decorateMetadataItem. Undefined for types without a registered + * Zod schema (function/service/router). Lets the Studio edit + * page surface invalid-metadata banners + inline field errors + * without a second round-trip. + */ + _diagnostics?: MetadataDiagnostics; + // ── ADR-0010 protection envelope ── + lock: MetadataLock; + lockReason?: string; + // `MetadataLockSource` (artifact | package | env-forced) — the only + // producer feeding this field on this path is the item-lock + // resolution's `lockSource` ([#21738] {@link resolveItemLock}: the + // binding layer's declared `_lockSource`), typed + // `MetadataLockSource | undefined`. The `'overlay'` arm this annotation + // used to carry was dead: the door's `'artifact' | 'overlay'` is the + // resolution's `layer`, which `getEffectiveLock` reports in its refusal + // text and which never feeds this response (commit 11b779e0f). + lockSource?: MetadataLockSource; + lockDocsUrl?: string; + provenance?: MetadataProvenance; + packageId?: string; + packageVersion?: string; + editable: boolean; + deletable: boolean; + resettable: boolean; + }> { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The + // three-layer diagnostic must answer for ONE namespace, or `code` and + // `overlay` can be read from two. + request = canonicalizeMetaRequestType(request); + // [#7557, #24097] A by-name API face of its own (`?layers=true`), which + // reads its layers without going through {@link getMetaItem}, so it + // refuses a disabled package's object itself: one answer per door. + if (request.type === 'object' && isObjectOfDisabledPackage(host.engine?.registry, request.name)) { + throw objectPackageDisabledError(request.name); + } + // [#26699] Ruling 6116165299 (Q2 i), extended to this read by the + // option-A ruling on #26649: ONE member of a bundled type, the + // selector's or else the canonical one, read exactly as + // {@link getMetaItem} reads it. Every layer below is that member's or + // nothing — the code layer's service copy, artifact and registry entry, + // and the stored row — so a named member nothing holds answers no + // layer, which the door answers as an absent item, ⛔ never with the + // canonical member's layers. + const member = resolveBundleMemberSelector(request.type, request.member); + const wantedMember = bundleMemberDiscriminator(request.type, member); + const isWantedMember = (candidate: unknown): boolean => + wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember; + + // [ADR-0131 D6] Environment → code: the overlay layer is the + // environment's stored row; no organization's row is read. + + // ── code layer: MetadataService.get + registry, BYPASSING overlay ── + let code: unknown | null = null; + let codeDegraded: { degraded: boolean; errors: string[] } | undefined; + try { + // ADR-0048 — package-scope the code layer so a same-name + // collision resolves to the requested package's artifact. + const fromService = await host.readItemFromMetadataService( + request.type, + request.name, + request.packageId, + ); + if (fromService.data !== undefined && fromService.data !== null) { + // [#26699] …when it is the member this read addresses, as in + // {@link getMetaItem}: the service keys a type's items by name + // alone, so for a bundle it holds whichever member it + // registered last. Another member's copy is no layer of this + // one; the artifact and registry lookups below ask for it. + // [#7556] The CODE layer of an object is D9.6's "owner's + // declaration with its extenders folded on", so the + // MetadataService copy is its base, not the layer itself. + // `effective` is `overlay ?? code`, so an object with no + // overlay row — the ordinary shape — is corrected by this + // single fold on both layers the diagnostic reports. + if (isWantedMember(fromService.data)) { + code = host.foldObjectExtendersFromRegistry( + request.type, request.name, fromService.data, + ); + } + } else if (fromService.degraded) { + // [#5840] Kept, not swallowed — acted on after the registry + // fallback below, which may still produce a real code layer. + codeDegraded = fromService; + } + } catch { + // ignore + } + if (code === null) { + // Prefer the artifact-only lookup so an overlay row hydrated + // into the registry's plain key can't masquerade as the "code + // default" layer; fall back to getItem for runtime-only items. + // + // [#21059] …and "runtime-only" is held to what it says. The plain + // key is one slot for two populations: an item registered at + // runtime with no package, and a stored row the hydrator put there + // ({@link hydrateOverlayIntoRegistry}, the boot `object` limb of + // {@link loadMetaFromDb}). For a name no package ships the + // artifact lookup misses, so `getItem` answered the hydrated row + // and the code layer became the stored body — but only once a + // hydration had run, so the same read answered `code: null` before + // it. The spec's layer 1 is "`null` when no artifact ships this + // item (it exists only as an overlay)", and #5707 / #5840 allow a + // layer only from a read that happened: a stored row is the overlay + // read below, never an artifact read. + // + // The discriminator is the tenant marker the hydrator ALREADY + // writes on every row it registers — ADR-0010 `_provenance: 'org'`, + // read through {@link isTenantAuthored}. ⛔ No new marker. A body + // that carries package-provenance stamps under a name no package + // ships is the same row: the hydrator restates its authorship over + // whatever its bytes claim, and a stamp is not an artifact read, so + // the envelope's provenance fields below read this item's code + // layer as `null` too (triage's ruling, overturnable by the + // maintainer); its lock reads no code layer at all ([#21738], the + // item-lock resolution below). A + // runtime-registered item with no package carries no tenant marker + // and keeps its code layer. + // + // + // [#26699] Each lookup is asked for the addressed member, exactly + // as {@link getMetaItem}'s are: the artifact at the member (the + // canonical one when none was named), and the registry for the + // member named. The registry's bare-name read still answers a + // bundle that ships no canonical member; a registry that does not + // narrow by member can answer another one for a NAMED member, and + // that answer is not this member's layer. + const runtimeOnly = (item: unknown): unknown => + item !== undefined && isTenantAuthored(item) ? undefined : item; + const namedMemberOnly = (item: unknown): unknown => + member !== undefined && item !== undefined && !isWantedMember(item) ? undefined : item; + const artifact = host.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember); + let regItem = (artifact !== undefined && isWantedMember(artifact) ? artifact : undefined) + ?? namedMemberOnly(runtimeOnly(host.engine.registry.getItem(request.type, request.name, request.packageId, member))); + if (regItem === undefined) { + const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; + if (alt) regItem = namedMemberOnly(runtimeOnly(host.engine.registry.getItem(alt, request.name, request.packageId, member))); + } + if (regItem !== undefined) code = regItem; + } + + // [#5840] The code half of the rule #5707 wrote for the overlay half, + // eleven lines below. `code: null` is not a shrug — this method states + // it positively ("no packaged/code-layer definition exists"), and the + // response then DERIVES from it: `effective` and the envelope's + // provenance fields. (It used to derive the lock too, from + // `code ?? overlay`, so an item whose code layer declared `_lock: + // 'full'` was rendered `editable: true, deletable: true` when the read + // that would have found that lock failed. [#21738] The lock is now the + // item-lock resolution's, over the registry's artifact lookup, which + // this read cannot lose.) An availability failure stated as an + // authorship fact is precisely what ADR-0110 D3 forbids, and the + // overlay half of this very method already refuses to do it — the two + // halves were asymmetric only because the loader failure was invisible + // on this side. + // + // Same narrow shape as the overlay half: the benign "nothing there" + // still returns `code: null` normally (a clean miss is not degraded), + // and a registry hit above is a real code layer, so this fires only + // when the null would otherwise be an unfounded authorship claim. + if (code === null && codeDegraded?.degraded) { + host.throwMetadataServiceUnavailable(codeDegraded.errors); + } + + // ── overlay layer: the environment's sys_metadata row (ADR-0131 D6) ── + let overlay: unknown | null = null; + let overlayScope: 'env' | null = null; + // [#28412] The stored row `overlay` was read from, when the served-row + // resolution read one: what {@link declinesStoredRow} judges a shipped + // flow name's declared override on. + let overlayRow: ServedOverrideRow | undefined; + // [#21761] The `overlay` layer of the one item-lock resolution below, + // selected from this read's address as in {@link getMetaItem}: the + // strictest lock among the item's stored rows in scope, never only the + // row this layer reports. + let overlayLockLayer: unknown; + // [#23291] Naming no package, the overlay comes from the address + // {@link getMetaItem} serves its content from + // ({@link packagelessReadAddress}), so `effective` is what that read + // returns and `/published` serves the same document. Content only: the + // lock stays at the request's address, as the `_lock` gate judges it. + // Asked outside the `try` below: its own failed read already answers + // the #5532 envelope, with the driver's error as `cause`. + const contentPackageId = request.packageId || await host.packagelessReadAddress(request.type, request.name, member); + try { + // ADR-0048 prefer-local. [#21716] The ONE served-row resolution + // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. + // [#26699] At the addressed member, as that read asks it. + // [#28409, ADR-0146 D8 item 2] …with the package's declared + // override first, as {@link getMetaItem} serves it: `overlay` is + // then the `.custom` item, `code` the base's document and + // `effective` the override (the base's document stays reachable + // here, ADR-0146 "Decided here" item 5). + let served: { row: any; overrideOf?: string } | undefined = await host.findServedOverlayRow({ + type: request.type, + name: request.name, + state: 'active', + ...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}), + ...memberOption(member), + servedOverride: true, + }); + // [#28830, #28390 ruling A] The pair's own added member is the + // overlay on the base's address, as the by-name read serves it + // ({@link findPairAddedMemberRow}); another package's never is. + if (!served && wantedMember !== undefined && typeof contentPackageId === 'string') { + const row = await host.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember); + if (row) served = { row }; + } + overlayLockLayer = await host.overlayLockLayerAt({ + type: request.type, + name: request.name, + packageId: request.packageId, + }, { otherSpelling: true }); + if (served) { + const rec = served.row; + overlayRow = rec as ServedOverrideRow; + overlay = host.convertStoredItem( + String(rec.type ?? request.type), + storedRowDocument(rec), + ); + overlayScope = 'env'; + } + } catch (error) { + // [#5707] The same rule as the four overlay reads in + // `getMetaItems` / `getMetaItem` (#5532), on the one overlay read + // PR #5705 deliberately did not reach. + // + // Swallowing here does not answer 404 — it answers something this + // method states positively in THREE fields at once: `overlay: null` + // ("nothing was ever customised"), `overlayScope: null` ("no row + // is stored"), and `effective = code` ("what runs today is the + // packaged artifact, verbatim"). The whole point of the layered + // read is to show an author what they changed; during an outage it + // told them they had changed nothing, which is the #5532 error — + // an availability failure reported as an authorship fact — landing + // in the diff view instead of on a 404. + // + // The benign case is unchanged and is why this is not a bare + // rethrow: an unprovisioned `sys_metadata` genuinely holds no + // overlay row, so `overlay: null` / `effective = code` IS the truth + // and first boot still renders the code layer. + // See {@link rethrowUnlessMetadataStoreUnprovisioned}. + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + + // ── [#21442] A row-less name a stored view container expands ── + // + // No row of this name was read above. When a stored view container in + // this caller's scope expands the name ({@link resolveRowlessExpandedView}, + // the list read's own selection and expansion), `effective` is the + // expanded item — what {@link getMetaItem} answers for the name — and + // the layers say where it came from in the fields that already say it: + // `overlay` is the container's own stored row, the one stored layer + // behind this name (its `name` is the container's, its `_packageId` the + // package its row is bound to), and `overlayScope` `'env'`. `code` keeps its own read: the item a package ships + // under this name (a tenant overlay of that package's container), else + // `null`. + let expandedFrom: RowlessExpandedView | undefined; + if (overlay === null) { + expandedFrom = await host.resolveRowlessExpandedView(request); + if (expandedFrom !== undefined) { + overlay = expandedFrom.container.data; + overlayScope = 'env'; + } + } + + // ── [#28409, ADR-0146 D8; N-09 rider gap 3] An ORPHANED row of the name ── + // + // Nothing stored is served at the address of a name its package X + // ships (`code` is X's document), and yet a row of the name is stored in X's pair + // (`.custom`) without the override declaration, or in a package + // that depends on X (X is in its manifest's dependency closure, + // {@link resolveWritePackageScope}). The shape a base upgrade leaves: + // the dependent or the pair saved the name before a later version of + // X shipped it. Since the effective read serves X's document under + // X's name (D8 item 1), that row is no longer served there, and the + // env-wide list lists the name once, as X's. It is still the + // package's own item, under its own address. The layered read of X's + // name REPORTS it, so it is never silently dropped: `overlay` holds + // the row as stored, a shadowed layer beside the effective one, as for + // a shipped flow name's stored row (#21002), and `effective` stays the + // code layer the by-name read serves. The flags below are the address's + // as before: the orphan is not this address's row. + // + // A row of a package outside X's closure is not an orphan of X's name: + // it is that package's own item (ADR-0048 §3.3), and not reported here. + // With several such rows, the pair's is reported, else the first + // package's in id order. + let orphaned = false; + if (overlay === null && code !== null && typeof contentPackageId === 'string' && contentPackageId !== '') { + const base = contentPackageId; + let rows: readonly StoredOverlayRow[] = []; + try { + rows = await host.readItemActiveRows(request.type, request.name); + } catch (error) { + // [#5707] The same rule as the overlay read above: a read that + // did not happen is not an item with no orphan. + host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const memberWhere = overlayMemberWhere(request.type, member); + const boundTo = (row: StoredOverlayRow): string | undefined => boundPackageOf(row.package_id); + const orphan = rows + .filter((row) => rowMatchesOverlayWhere(row, memberWhere)) + .filter((row) => { + const owner = boundTo(row); + if (owner === undefined || owner === base) return false; + if (isServedOverrideRowOf(row, base)) return false; + const pair = customizationPackageId(base); + if (pair.ok && pair.value === owner) return true; + return host.resolveWritePackageScope(owner)?.dependencies.includes(base) === true; + }) + .sort((a, b) => { + const pair = customizationPackageId(base); + const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1); + return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b))); + })[0]; + if (orphan !== undefined) { + overlay = host.convertStoredItem( + String((orphan as { type?: unknown }).type ?? request.type), + storedRowDocument(orphan), + ); + overlayScope = 'env'; + orphaned = true; + } + } + + // [#4513] `effective` is documented above as "what `getMetaItem` would + // return", and the response's `_diagnostics` is computed from it — so it + // carries the same audit-family governance that read now applies, or the + // sentence stops being true the moment the overlay declares a writable + // `created_at`. `code` and `overlay` are deliberately left RAW: they are + // the diagnostic's whole point (what the package shipped vs what was + // customised), and a Studio diff showing `code`'s declaration next to + // `effective`'s governed value is the platform override made visible, + // not a defect. + // [#8027] …and `effective` is "what `getMetaItem` would return", so when + // the overlay wins it is that read's BASE LAYER, resolved the same way: + // D9.2's base-plus-extenders. Without this the single `?layers=true` + // response contradicted itself — `code` carried the extension fields + // (#7556 folds it) and `effective` did not, with the `overlay` layer + // showing a customisation that explained none of the difference. + // + // ⛔ The fold lands on the EFFECTIVE base only. `overlay` stays the row + // the tenant actually stored: a code-declared extension is not a tenant + // customisation, and #7556 drew that boundary deliberately (the same + // reason `governServedItem` is called here and never on `overlay`). + // + // ── [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ── + // + // `flow` is Regime C: the packaged base is locked, "⛔ Never silent + // override, never an overlay read path". For a flow name the loader's + // set holds, the flattened view (#20913) and {@link getMetaItem} + // (#20946) serve the loader's body, and a stored row of that name is + // neither merged into the package's slot nor lets it stand in for it. + // `effective` is "what `getMetaItem` would return", so for such a name + // it is the CODE layer, decided by the SAME stored-row predicate, + // {@link isShippedFlowName}, judged by NAME — ⛔ no fourth precedence + // path of this method's own. Overlay-wins here served the stored body + // as the effective layer while the lock and provenance flags (resolved + // from `code` first, below) named the package. + // + // The stored row is still REPORTED: `overlay` / `overlayScope` keep the + // row as stored, a shadowed layer of its own scope beside the effective + // one, never the effective layer itself. The code layer needs no + // registry-half call ({@link isStoredFlowEntryOfShippedName}): it reads + // the loader's set (`lookupArtifactItem`, blind to tenant-authored rows) + // before the registry's bare slot, and a shipped name is by definition + // one that set holds. Every other type, and a flow name no managed + // package ships, keeps overlay-wins. What becomes of the stored rows + // themselves (keep, refuse, migrate) is not decided here. + // + // [#21922] The same holds for a code-defined DATASOURCE name, through + // the one predicate both name classes share ({@link declinesStoredRow}): + // `getMetaItem` serves the MetadataService's code definition, so that is + // `effective` here (the code layer above reads the MetadataService + // first), and the stored row stays reported in `overlay` — the residue + // the `/meta` DELETE removes as repair. + // + // [#21442] A name a stored container expands (above) takes the expanded + // item as its effective layer: the container row in `overlay` is the + // layer it derives from, not the value the by-name read serves. + // [#28409] …and an orphaned row (above) is a shadowed layer of the + // same kind: `effective` is the code layer the by-name read serves. + const effectiveBase: unknown | null = expandedFrom !== undefined + ? expandedFrom.item + : overlay !== null && !orphaned && !host.declinesStoredRow(request.type, request.name, overlayRow) + ? host.foldObjectExtendersFromRegistry(request.type, request.name, overlay) + : code; + // [#21738] The lock is the one item-lock resolution's — the + // `getMetaItem` call over [#21803] every installed package that ships + // the name ({@link artifactLockLayerAt}) and [#21761] the rows in + // scope for its address — never `code ?? overlay`, which took the code + // layer's (absent) lock over a stored row's `_lock`. A row-less name a + // stored container expands contributes no `overlay` layer: the + // container's row is not this name's row, and the `_lock` gate does not + // read it. The provenance fields still come from the layer the + // response reports first. + const itemLock = resolveItemLock({ + artifact: host.artifactLockLayerAt({ + type: request.type, + name: request.name, + packageId: request.packageId, + }), + overlay: overlayLockLayer, + }); + // [#21761, #21803] `effective` is what {@link getMetaItem} would + // return, and that read's body carries the resolution's lock family + // (the binding row's or the binding package's, which need not be the + // layer `effective` was taken from). So this one does too. `code` and + // `overlay` stay the layers as shipped and as stored. + const effective: unknown | null = withItemLockFamily( + host.governServedObject(request.type, effectiveBase), + itemLock, + ); + + const _diagnostics = + effective !== null && effective !== undefined + ? computeMetadataDiagnostics(request.type, effective) + : undefined; + + // ADR-0010 — surface lock/provenance flags so the Studio editor + // can render the correct affordances without a second round trip. + const artifactBacked = host.isArtifactBacked(request.type, request.name); + // [#21670] …joined with the locked-packaged-base verdict the write + // doors answer — the same derivation `getMetaItem` publishes. + const lockState = host.servedLockState( + request.type, request.name, code ?? overlay ?? {}, artifactBacked, itemLock, + overlay !== null && overlay !== undefined && !orphaned, + ); + + // [#8154] The per-type credential redaction, on the ONE read exit + // `decorateMetadataItem` does not reach — this method never calls it + // (it computes `_diagnostics` directly, above) and serves its three + // layers raw. Measured on `origin/main` before this change: a legacy + // `datasource` row came back with `config.password` in cleartext in + // BOTH `overlay` and `effective`, so a hook that covered only the + // decorated exits would have left this door open while the report said + // "the read path is closed" — the exact shape this card exists to end. + // + // ⚠️ ALL THREE LAYERS, `code` and `overlay` included, and that is a + // reading of ANOTHER lane's ruling rather than an inference from this + // one. #7556 keeps `code` / `overlay` deliberately RAW — ungoverned, + // uninjected, unfolded — so that a Studio diff shows what the tenant + // actually customised and nothing else. That reason does not extend to + // credentials, and redacting the key on BOTH sides leaves the diff + // itself unchanged: the key is absent from both layers, so "what was + // customised" reads exactly as before. Stated here, and in the PR body, + // so `domain:engine-core` / `domain:spec` reviewers can contest it + // rather than discover it. ⛔ It is NOT a licence to fold, govern or + // inject on these layers — redaction subtracts, and only a credential. + // + // Placed AFTER `_diagnostics` and AFTER `servedLockState`, both of + // which must read the raw bodies: the diagnostics ordering is the + // migration-inventory badge (see `decorateMetadataItem`), and a lock + // resolved from a redacted body would be a lock resolved from a + // document this method is not serving. + const servedCode = redactMetadataItem(request.type, code); + const servedOverlay = redactMetadataItem(request.type, overlay); + const servedEffective = redactMetadataItem(request.type, effective); + + return { + type: request.type, + name: request.name, + code: servedCode, + overlay: servedOverlay, + overlayScope, + effective: servedEffective, + ...(_diagnostics ? { _diagnostics } : {}), + lock: lockState.lock, + ...(lockState.lockReason !== undefined ? { lockReason: lockState.lockReason } : {}), + ...(lockState.lockSource !== undefined ? { lockSource: lockState.lockSource } : {}), + ...(lockState.lockDocsUrl !== undefined ? { lockDocsUrl: lockState.lockDocsUrl } : {}), + ...(lockState.provenance !== undefined ? { provenance: lockState.provenance } : {}), + ...(lockState.packageId !== undefined ? { packageId: lockState.packageId } : {}), + ...(lockState.packageVersion !== undefined ? { packageVersion: lockState.packageVersion } : {}), + editable: lockState.editable, + deletable: lockState.deletable, + resettable: lockState.resettable, + }; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index deb8d9fd3a7..03b6888b0bc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -68,11 +68,6 @@ import { // it prints it at. findUndeclarableFieldType, isObjectFieldTypeRefused, - // [#21059] The tenant marker the hydrator writes on every stored row it - // registers (ADR-0010 `_provenance: 'org'`) — read by - // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer - // fallback so a hydrated row is never answered as the code layer. - isTenantAuthored, // [ADR-0131 D6/D7] A legacy organization's rows, off the physical column, // on a database the ceremony has not migrated — see migrateStoredMetadata. readLegacyOrganizationRows, @@ -106,10 +101,9 @@ import { ImportUnmanagedPackageRequestSchema } from '@objectstack/spec/api'; import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; -// [#24395] The save's authoring surface, and the draft row's per-key -// provenance read back for the strict draft read. +// [#24395] The save's authoring surface. import { MetadataAuthoringSurfaceSchema } from '@objectstack/spec/api'; -import { authoringClientOf, storedDraftChanges } from './draft-changes.js'; +import { authoringClientOf } from './draft-changes.js'; import { parseFilterAST, isFilterAST, @@ -191,11 +185,7 @@ import { REFERENCE_SITES } from './reference-sites.js'; // module still has (#3950), so it is removed rather than parked. import { computeMetadataDiagnostics, - computeViewReferenceDiagnostics, - decorateMetadataItem, type MetadataDiagnostics, - withFormLayoutWarnings, - withPublishVerdict, } from './metadata-diagnostics.js'; // [#8154] The per-type read-path redaction seam. `decorateMetadataItem` already // composes `redactMetadataItem` (see its ordering note), which covers the @@ -497,9 +487,6 @@ export { } from './protocol-meta-type-schema.js'; import { isItemOfDisabledPackage, - isObjectOfDisabledPackage, - noPendingDraftError, - objectPackageDisabledError, packagelessPublishNoDraftRefusal, permissionSetOwnerBindingRefusal, protectionBlockRefusal, @@ -545,7 +532,6 @@ export { import { boundPackageOf, bundleMemberOf, - envelopePackageId, isShippedViewItem, mergeArtifactProtection, stateTenantAuthorship, @@ -723,6 +709,9 @@ import { getMetaItemsPerPackage, readFlattenedMetaItems, } from './protocol-meta-list-reads.js'; +// [#25467 E2 cut 1b] The metadata item reads and the layered reads. +import { getMetaItem, getMetaItemForExecution, readMetaItem } from './protocol-meta-item-reads.js'; +import { getMetaItemLayered, packagelessLayeredReadRefusal } from './protocol-meta-layered-reads.js'; /** * [#4435/#5138] The 404 a single-record operation answers when the id names no @@ -1277,31 +1266,53 @@ export class ObjectStackProtocolImplementation implements convertStoredItem: (...a) => protocol.convertStoredItem(...a), declinesStoredRow: (...a) => protocol.declinesStoredRow(...a), disabledActionsOf: (...a) => protocol.disabledActionsOf(...a), + draftPublishVerdict: (...a) => protocol.draftPublishVerdict(...a), get engine() { return protocol.engine; }, get environmentId() { return protocol.environmentId; }, expandStoredViewContainers: (...a) => protocol.expandStoredViewContainers(...a), findEnvironmentRows: (...a) => protocol.findEnvironmentRows(...a), + findPairAddedMemberRow: (...a) => protocol.findPairAddedMemberRow(...a), + findServedOverlayRow: (...a) => protocol.findServedOverlayRow(...a), flowRegistrationsOf: (...a) => protocol.flowRegistrationsOf(...a), foldObjectExtendersFromRegistry: (...a) => protocol.foldObjectExtendersFromRegistry(...a), get getServicesRegistry() { return protocol.getServicesRegistry; }, governServedObject: (...a) => protocol.governServedObject(...a), hydrateOverlayIntoRegistry: (...a) => protocol.hydrateOverlayIntoRegistry(...a), + isArtifactBacked: (...a) => protocol.isArtifactBacked(...a), isStoredEntryOfDeclinedName: (...a) => protocol.isStoredEntryOfDeclinedName(...a), + isStoredFlowEntryOfShippedName: (...a) => protocol.isStoredFlowEntryOfShippedName(...a), isUnservedSealedOverlay: (...a) => protocol.isUnservedSealedOverlay(...a), isWritablePackage: (...a) => protocol.isWritablePackage(...a), lookupArtifactItem: (...a) => protocol.lookupArtifactItem(...a), namesWithOwnStoredRow: (...a) => protocol.namesWithOwnStoredRow(...a), + overlayLockLayerAt: (...a) => protocol.overlayLockLayerAt(...a), overrideWriteVerdict: (...a) => protocol.overrideWriteVerdict(...a), packagedArtifactOwner: (...a) => protocol.packagedArtifactOwner(...a), packagedBaseRefusal: (...a) => protocol.packagedBaseRefusal(...a), + packagelessAddress: (...a) => protocol.packagelessAddress(...a), + packagelessReadAddress: (...a) => protocol.packagelessReadAddress(...a), readActiveOverlayRows: (...a) => protocol.readActiveOverlayRows(...a), readFlattenedMetaItems: (...a) => protocol.readFlattenedMetaItems(...a), + readItemActiveRows: (...a) => protocol.readItemActiveRows(...a), + readItemFromMetadataService: (...a) => protocol.readItemFromMetadataService(...a), + readMetaItemFace: (...a) => protocol.readMetaItemFace(...a), + readVersionToken: (...a) => protocol.readVersionToken(...a), + registryMemberServedAt: (...a) => protocol.registryMemberServedAt(...a), + resolveAppPackageIdKey: (...a) => protocol.resolveAppPackageIdKey(...a), + resolveCascadeDeleteObjects: (...a) => protocol.resolveCascadeDeleteObjects(...a), + resolveDisabledActionsReader: (...a) => protocol.resolveDisabledActionsReader(...a), + resolveFlowRegistrationReader: (...a) => protocol.resolveFlowRegistrationReader(...a), + resolveRowlessExpandedView: (...a) => protocol.resolveRowlessExpandedView(...a), + resolveWritePackageScope: (...a) => protocol.resolveWritePackageScope(...a), rethrowUnlessMetadataStoreUnprovisioned: (...a) => protocol.rethrowUnlessMetadataStoreUnprovisioned(...a), + servedLockState: (...a) => protocol.servedLockState(...a), shippedArtifactsOf: (...a) => protocol.shippedArtifactsOf(...a), shippingPackagesOf: (...a) => protocol.shippingPackagesOf(...a), shipsBundleName: (...a) => protocol.shipsBundleName(...a), storedFlowBindingAgrees: (...a) => protocol.storedFlowBindingAgrees(...a), storedOverlayEntries: (...a) => protocol.storedOverlayEntries(...a), + strictDraftReadAddress: (...a) => protocol.strictDraftReadAddress(...a), + throwMetadataServiceUnavailable: (...a) => protocol.throwMetadataServiceUnavailable(...a), uninstalledNamedBaseRefusal: (...a) => protocol.uninstalledNamedBaseRefusal(...a), })); } @@ -4578,9 +4589,8 @@ export class ObjectStackProtocolImplementation implements /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ member?: string, }) { - // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. Folded - // HERE, at each face's boundary, as {@link getMetaItems} folds. - return this.readMetaItemFace(canonicalizeMetaRequestType(request), 'served'); + // [#25467 E1] The body moved to `protocol-meta-item-reads.ts`; it reaches this class through `protocol-host.ts`. + return getMetaItem(protocolHost(this), request); } /** @@ -4614,8 +4624,8 @@ export class ObjectStackProtocolImplementation implements /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ member?: string, }) { - // The same #4432 fold {@link getMetaItem} applies, for the same reason. - return this.readMetaItemFace(canonicalizeMetaRequestType(request), 'execution'); + // [#25467 E1] The body moved to `protocol-meta-item-reads.ts`; it reaches this class through `protocol-host.ts`. + return getMetaItemForExecution(protocolHost(this), request); } /** @@ -4663,685 +4673,8 @@ export class ObjectStackProtocolImplementation implements type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, member?: string, }, opts: { face?: 'served' | 'execution' } = {}) { - // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. - request = canonicalizeMetaRequestType(request); - // [#23183] ADR-0048 §3.1: the app read keyed by PACKAGE ID, resolved - // ONCE, here, to the package's one app read by its own name in that - // package, so every step below (the stored row, the lock, the version - // token) reads the app exactly as its by-name read does. See - // {@link resolveAppPackageIdKey}. - request = await this.resolveAppPackageIdKey(request); - // [#22877] Ruling 6116165299 (Q2 i): ONE member of a bundled type is - // addressed by name, the selector's or else the canonical one. Every - // layer below answers that member or nothing — the stored row, the - // code layer's member and the registry's. - const member = resolveBundleMemberSelector(request.type, request.member); - const wantedMember = bundleMemberDiscriminator(request.type, member); - const isWantedMember = (candidate: unknown): boolean => - wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember; - let item: unknown; - // [ADR-0131 D6] Environment → code: the environment's stored row, - // else the code packages' definition. No organization's row is read — - // a legacy organization-scoped row is refused at boot - // (the v18 boot refusal, ADR-0131 D10 item 5) and never served. - // Studio's editor opens a draft buffer with `state: 'draft'`; - // runtime loaders omit it and get the live published row. - const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; - // [#24535] The serving decorations at this read's exits are the ONE - // thing the two API faces do not share. The served face (and an - // authoring caller) decorates as before; the execution face hands the - // governed item back as resolved ({@link getMetaItemForExecution}). - // Every read of `execution` below is one of those exits. - const execution = opts.face === 'execution'; - const serveItem = (resolved: T): T => (execution ? resolved : decorateMetadataItem(request.type, resolved)); - // [#29196] A served VIEW's `_diagnostics` also carries the lint's - // `form-field-unknown` warnings ({@link withFormLayoutWarnings}), judged - // against the object the registry serves for each name the view spells. - // Applied at every served exit of this read, the draft ones included; a - // warning, so `valid` is left as the schema answered it. The execution - // face serves no `_diagnostics`. - const serveFormLayout = (served: T): Promise => (execution - ? Promise.resolve(served) - : withFormLayoutWarnings(request.type, served, (name) => this.engine?.registry?.getObject?.(name))); - // [#26707] A served DRAFT is decorated as above and then told what its - // publish would refuse: the runtime gate's verdict on the stored draft - // body ({@link draftPublishVerdict}), folded into `_diagnostics` - // ({@link withPublishVerdict}). Judged on the row as stored — the body - // the promotion judges — never on the governed, converted or redacted - // copy this read serves. The execution face serves no `_diagnostics`. - const serveDraft = async (resolved: T, row: { metadata?: unknown; package_id?: string | null }): Promise => { - const served = await serveFormLayout(serveItem(resolved)); - if (execution || !served || typeof served !== 'object') return served; - const issues = await this.draftPublishVerdict({ - type: request.type, - name: request.name, - body: storedRowDocument(row), - packageId: row.package_id ?? null, - }); - const before = (served as { _diagnostics?: MetadataDiagnostics })._diagnostics; - const after = withPublishVerdict(request.type, request.name, before, issues); - return after === before ? served : { ...served, _diagnostics: after }; - }; - // ── [#20946, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ── - // - // `flow` is Regime C: the packaged base is locked, "⛔ Never silent - // override, never an overlay read path". Since #20913 the flattened view - // ({@link readFlattenedMetaItems}, both faces) serves a flow name the - // loader's set holds from the loader's own entries alone, with two - // predicates. This read applies the SAME two, and ⛔ no third precedence - // path of its own: - // • the stored-row half, {@link isShippedFlowName}, judged by NAME — - // step 1 below does not adopt the stored row of such a name, whatever - // its package binding or the stamps its own body carries; - // • the registry half, {@link isStoredFlowEntryOfShippedName} — step 3 - // does not serve the registry's bare copy of that row (the hydrated - // tenant row `getItem` answers first); the loader's entry is served. - // Adopted, the row was served with the artifact's protection envelope - // grafted over it (the merge at the end of this method): a stored body - // answered as the package's definition by name, while the list answered - // the loader's body for the same name. - // - // Scoped to the ACTIVE read. A draft is answered as a draft (the strict - // `state: 'draft'` read and the `previewDrafts` arm), never under the - // artifact's envelope, and the list's own preview arm is equally - // unfiltered. Organization-scoped rows never reach this read - // (ADR-0131 D6). What becomes of the stored rows themselves (keep, refuse, migrate) is - // not decided here. - // - // ── [#21922, ADR-0062 D4, ADR-0126 §3] A code-defined DATASOURCE name ── - // - // The stored-row half covers a second name class, through the same - // predicate ({@link declinesStoredRow}): a datasource name the host - // registers from code ({@link isDeclaredCodeDatasource}). "Code wins on - // collision": its code definition is the MetadataService's in-memory - // registration, which step 2 serves, and a stored row under the name is - // residue, never a layer of it. Adopted, the row was served here while - // the list, the admin door and the boot restore all served the code - // definition. This read needs no registry half for it: step 2 answers - // before step 3's bare registry slot, which is where a hydrated copy of - // the row sits. The row is still FOUND (`storedRowServed` below), so the - // `/meta` DELETE that removes it as repair stays `deletable`. A draft - // is answered as a draft, as above. - const storedRowDeclined = readState === 'active' && this.declinesStoredRow(request.type, request.name); - - // [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The package whose stored - // content a read that names no package serves: the address a save or - // a delete naming none writes ({@link packagelessAddress}), read from - // the item's ACTIVE rows at both states, as the draft save inherits - // its package from them (#11087). For an item bound to exactly one - // package, the read then serves what the read naming that package - // serves (its own row, else the package-less one); otherwise any row, - // the best-effort first match ADR-0048 §3.3 states. - // - // Before, it took the first row of any package. A save naming the - // package beside the item's package-unbound row mints the package's - // row and leaves the unbound one stored first, so this read kept - // serving the unbound body while the package-less save wrote the - // package's row, the package-less delete removed it, the list served - // it, and this read's own `version` named it (#23165). The package-less - // row is not converted or removed: ADR-0070 keeps it as a read-side - // detail and a migration source, and it still stands in for every - // package with no row of its own (ADR-0048 prefer-local). - // - // Content only: the lock is selected from the item's rows at the - // address the `_lock` gate judges a package-less save at, below. - // - // [#23715, ADR-0048, ADR-0070 TL;DR 1] The strict draft read that names - // no package takes its content from the draft its `version` names - // ({@link strictDraftReadAddress}), the address the draft save, the - // per-item publish and the draft discard naming none act on. The - // active rows above say nothing about an item never published into a - // package, so with drafts `[package-unbound, Q]` this read served the - // first draft row the store returned under the package-unbound - // draft's token, and a save pinned to that token wrote a row other - // than the one read (#8907). The draft-overlay preview below shares - // the address: it serves the draft the strict read serves. - const draftContentAddress = !request.packageId && (readState === 'draft' || request.previewDrafts) - ? await this.strictDraftReadAddress(request.type, request.name, member) - : undefined; - // [#24371] The item's ACTIVE rows, both spellings, read ONCE - // ({@link readItemActiveRows}): the package-less binding, the served - // row and the lock below each select from them by the `where` it would - // have sent the store. They were five serial reads of the same rows, - // on every `/data` request. [#10757 leg 6] On the API faces (served and, - // [#24535], execution) the read goes through the overlay cache; an - // authoring read is never kept. - let activeRows: StoredOverlayRow[] | undefined; - if (readState === 'active') { - try { - activeRows = await this.readItemActiveRows(request.type, request.name, opts.face !== undefined); - } catch (error) { - // [#5532] Only an unprovisioned store holds no row; any other - // failure is a read that did not happen, never "no row". - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - activeRows = []; - } - } - // The package-less binding ({@link packagelessReadAddress}'s), selected - // from those rows; the `version` below ({@link readVersionToken}) - // takes the same binding instead of reading it again. - const packagelessAddressed = readState === 'active' && !request.packageId - ? await this.packagelessAddress( - PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member, 'active', activeRows, - ) - : undefined; - const packagelessBinding = packagelessAddressed?.packageId; - // [#28238] The content, though, is {@link packagelessContentPackage}'s: - // the code package that ships the name is a candidate too, so a row - // another package stored under the name is not served for it. - const contentPackageId = request.packageId || (packagelessAddressed - ? packagelessContentPackage(packagelessAddressed, this.shippedArtifactsOf(request.type, request.name)) - : undefined); - - // ADR-0033 draft-overlay preview (non-strict): when the caller opts in - // (admin-gated upstream), prefer a `state='draft'` row if one exists, else - // fall back to the active read below. This differs from the strict - // `state:'draft'` mode, which 404s (`no_draft`) when no draft exists — the - // render path must degrade to the published value, not error. The draft - // item is tagged `_draft:true` so the UI can badge it. - if (request.previewDrafts && readState !== 'draft') { - try { - // [#21716] The served-row resolution, on the draft partition - // (ADR-0048 prefer-local, parity with the row read below) — see - // {@link findServedOverlayRow}. - // [#28409] A preview serves the package's name, so the - // pending edit of its declared override is a candidate - // ({@link servedOverlayRowCandidates}' `servedOverride`), as - // in the list's draft-preview merge. - const servedDraft = await this.findServedOverlayRow({ - type: request.type, - name: request.name, - state: 'draft', - ...(draftContentAddress - ? { packageId: draftContentAddress.packageId } - : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), - ...memberOption(member), - servedOverride: true, - }); - let draftRec = servedDraft?.row; - // [#23715] Two or more packages and no package-unbound draft: - // the best-effort first match, as the strict read below. - if (!draftRec && draftContentAddress?.bestEffort) { - draftRec = (await this.findServedOverlayRow({ - type: request.type, name: request.name, state: 'draft', ...memberOption(member), - }))?.row; - } - if (draftRec) { - const draftItem = this.convertStoredItem( - String(draftRec.type ?? request.type), - typeof draftRec.metadata === 'string' - ? JSON.parse(draftRec.metadata) - : draftRec.metadata, - ) as any; - let previewed = draftItem; - if (previewed && typeof previewed === 'object') { - const recPkg = (draftRec as { package_id?: string | null }).package_id ?? undefined; - // [#28409] A declared override's draft previews as the - // overridden package's item, as its active row serves. - if (servedDraft?.overrideOf !== undefined && servedDraft.row === draftRec) { - previewed = { ...previewed, _packageId: servedDraft.overrideOf }; - } else previewed = withRowBinding(previewed, recPkg); // [#28673] the column's package - (previewed as any)._draft = true; - } - return { - type: request.type, - name: request.name, - item: await serveDraft(this.governServedObject(request.type, previewed), draftRec), - }; - } - } catch (error) { - // [#5532] Falling through to the active read here would answer - // "there is no draft for this item" from a read that never - // reached the table the drafts live in. - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - } - - // 1. Customization overlay lookup (sys_metadata): the environment's - // row (ADR-0131 D6/D7: {@link findEnvironmentRow}) before falling - // through to the in-memory registry / MetadataService. - // ADR-0048 prefer-local (a package id prefers that - // package's row, then the package-less one, mirroring - // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE - // served-row resolution — see {@link findServedOverlayRow}. It - // decides the CONTENT only; the lock is selected below from this - // read's address ([#21761]). - // [#21899] Whether a stored row was found — the fact the origin-gated - // repair's `deletable` reads ({@link servedLockState}). - let storedRowServed = false; - // [#24395] The served draft row's per-key provenance — read off the - // row this read serves, so it can never describe another body. - let draftChanges: ReturnType; - // [#26707] The draft row this read serves, for its publish verdict. - let servedDraftRow: { metadata?: unknown; package_id?: string | null } | undefined; - try { - // [#28409, ADR-0146 D8 item 2] The active read serves the - // package's name, so its declared override is the first candidate - // ({@link servedOverlayRowCandidates}' `servedOverride`). The - // strict draft read answers the address a draft save writes, never - // the pair's: it asks without it. - const served = await this.findServedOverlayRow({ - type: request.type, - name: request.name, - state: readState, - ...(readState === 'draft' && draftContentAddress - ? { packageId: draftContentAddress.packageId } - : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), - ...memberOption(member), - ...(readState === 'active' ? { servedOverride: true } : {}), - }, activeRows); - let record = served?.row; - // [#28802, #28390 ruling A] A member of a bundle the address's - // package ships and does not ship at that member: the pair's own - // added member is the address's, served from the pair's row under - // the pair's binding (a dependent's member never is). - if (!record && readState === 'active' && wantedMember !== undefined && typeof contentPackageId === 'string') { - record = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember, activeRows); - } - // [#23715] Two or more packages and no package-unbound draft: the - // best-effort first match ADR-0048 §3.3 states, as before, served - // with `version: null` (no row at the address the save naming none - // resolves, and that save is refused there). - if (!record && readState === 'draft' && draftContentAddress?.bestEffort) { - record = (await this.findServedOverlayRow({ - type: request.type, - name: request.name, - state: readState, - ...memberOption(member), - }))?.row; - } - storedRowServed = record !== undefined && record !== null; - // [#20946, #21922] The stored-row half — see `storedRowDeclined` above. - // [#28412, ADR-0146 D9 item 1] Judged again on the row itself when - // the name declines: a shipped flow name's declared override, the - // row {@link findServedOverlayRow} serves first, is the packaged - // flow, served as the list's slot serves it ({@link declinesStoredRow}). - if (record && !(storedRowDeclined && this.declinesStoredRow(request.type, request.name, record as ServedOverrideRow))) { - item = this.convertStoredItem( - String(record.type ?? request.type), - storedRowDocument(record), - ); - // Surface the persisted software-package binding (parity with - // the list path in getMetaItems) so provenance/UI can read it. - const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; - if (served?.overrideOf !== undefined && served.row === record && item && typeof item === 'object') { - // [#28409] A declared override is served as the package's - // item it overrides, as the list's slot serves it: under - // that package's provenance, so the envelope below is the - // overridden package's artifact's ({@link envelopePackageId}). - item = { ...(item as Record), _packageId: served.overrideOf }; - } else { - // [#28673] The column's package, and only the column's ({@link withRowBinding}). - item = withRowBinding(item, recPkg); - } - if (readState === 'draft') { - draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes); - servedDraftRow = record as { metadata?: unknown; package_id?: string | null }; - } - } - } catch (error) { - // [#5532] THE site this issue was raised on. Falling through to the - // registry / MetadataService with `item` still `undefined` is what - // let a storage outage arrive at the client as `not found` (active - // read) or `NO_DRAFT` (draft read) — both of them claims about - // authorship, made from a read that never happened. - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - - // Draft reads stop here — they intentionally do NOT fall through - // to the runtime registry / MetadataService (which only know - // about published values). When the draft row is missing we - // throw `no_draft` (HTTP 404) so the REST contract is identical - // to `POST /publish` on an empty slot: clients use a single - // status code to decide "no pending edit" without sniffing - // envelope shape. See ADR-0005 §draft-lifecycle. - if (readState === 'draft') { - if (item === undefined) throw noPendingDraftError(request.type, request.name); - const governedDraft = this.governServedObject(request.type, item); - return { - type: request.type, - name: request.name, - item: servedDraftRow ? await serveDraft(governedDraft, servedDraftRow) : await serveFormLayout(serveItem(governedDraft)), - // [#22114] The draft row's version token — the one a draft save - // (`?mode=draft`) at this address accepts as `If-Match`. - // [#22877] The member's own row's, as a save of that member's - // body addresses it. - version: await this.readVersionToken({ ...request, member }, 'draft'), - // [#24395] `GetMetaItemResponseSchema.draftChanges`: present - // only when the draft row's saves recorded at least one entry - // — ⛔ never `[]` for a row that recorded none. - ...(draftChanges ? { draftChanges } : {}), - }; - } - - // [#21761] The item-lock resolution's `overlay` layer, selected from - // this read's ADDRESS ({@link overlayLockLayerAt}) by the selection - // the `_lock` gate makes: the strictest lock among the item's stored - // rows in scope, whichever package each is bound to. Not the row served - // above, which is the address's preferred CONTENT (ADR-0048), and read - // whether or not that row is adopted (a shipped flow name, #20946, and a - // code-defined datasource name, #21922, do not serve their stored row, - // and the gate binds it all the same). - let overlayLockLayer: unknown; - try { - overlayLockLayer = await this.overlayLockLayerAt({ - type: request.type, - name: request.name, - packageId: request.packageId, - }, { otherSpelling: true }, activeRows); - } catch (error) { - // [#5532] The same rule as the row read above: a lock read that did - // not happen is not an unlocked item. - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - - // [#8027] An OBJECT's overlay row is a BASE LAYER, not a resolved - // schema. ADR-0029 D9.2 defines the resolution as `overlay ?? own` with - // the `extend` contributors folded ON — which is what - // `SchemaRegistry.resolveObject` does for an overlay it knows about, and - // what step 2 below already does (#7556) for the MetadataService copy. - // Step 1 was the one adopter that served its layer verbatim, so a single - // customisation row — an admin renaming the object's label in Studio — - // silently dropped every extension-contributed field from this read, and - // therefore from every writable form derived from it, while the data API - // kept accepting and persisting those same fields. - // - // Placed AFTER the draft return on purpose: a draft is a pending edit of - // the base layer, and folding it would show an author extension fields - // inside the document they are editing and about to PUT back. Drafts were - // never folded (before #7556 nothing was), so this leaves that asymmetry - // exactly where it already was rather than widening this fix into it. - // - // No-op for every type but `object`, for an object nothing extends, and - // for an item with no overlay row (`item` is still undefined here, and - // step 2 / step 3 fold their own sources). - if (item !== undefined) { - item = this.foldObjectExtendersFromRegistry(request.type, request.name, item); - } - - // 1b. [#21442] A view name with no stored row of its own that a stored - // view container in this caller's scope EXPANDS — the item the - // object door (`GET /meta/view?object=`) lists under this name, - // resolved by {@link resolveRowlessExpandedView} through the list - // read's own selection and expansion. Placed before steps 2 and 3 - // because the list read's expansion wins over the MetadataService - // and registry items of the same name too; there, a name a package - // ships (`.default` under a tenant overlay of that package's - // container) answered the packaged row while the list served the - // expansion, and on an unscoped kernel a hydrated registry copy - // answered only for an environment-wide container. ⛔ No - // kernel-specific branch: every kernel answers here. - if (item === undefined) { - const expanded = await this.resolveRowlessExpandedView(request); - if (expanded !== undefined) item = expanded.item; - } - - // 2. MetadataService (runtime-registered items: HMR-updated view/page/ - // dashboard/agent/tool, plus FilesystemLoader-sourced items). This - // is consulted BEFORE the in-memory SchemaRegistry because the - // registry is a boot-time cache populated by `loadMetadataFromService` - // and is NOT invalidated on `MetadataManager.register()` (which is - // how the CLI dev watcher pushes recompiled metadata into the - // running server). Without this ordering, edits to `*.view.ts` - // source files appear to take effect (MetadataManager learns the - // new value) but reads continue to return the stale registry copy. - // [#5840] `serviceDegraded` survives past the registry step below on - // purpose — see the branch that reads it after step 3 for why the - // verdict cannot be acted on here. - let serviceDegraded: { degraded: boolean; errors: string[] } | undefined; - if (item === undefined) { - try { - // Threads the caller's package id (ADR-0048) so a single-item - // fetch is package-scoped: when two installed packages ship the - // same type/name, the facade prefers the requester's own item. - const fromService = await this.readItemFromMetadataService( - request.type, - request.name, - request.packageId, - ); - if (fromService.data !== undefined && fromService.data !== null) { - // [#22877] …when it is the member this read addresses. The - // service keys a type's items by name alone, so for a - // bundle it holds whichever member it registered last, and - // that one answered every member's read. - // [#7556] A layer, not a resolved schema — see - // {@link foldObjectExtendersFromRegistry}. No-op for every - // type but `object`, and for an object nothing extends. - if (isWantedMember(fromService.data)) { - item = this.foldObjectExtendersFromRegistry( - request.type, request.name, fromService.data, - ); - } - } else if (fromService.degraded) { - serviceDegraded = fromService; - } - } catch { - // MetadataService not available — fall through - } - } - - // 3. In-memory SchemaRegistry (artifact-loaded out-of-box values, and - // items that bypass MetadataService — e.g. some object-schema - // extension chains registered by AppPlugin directly). - // Both control-plane (unscoped) and project kernels consult the - // registry. The previous guard that skipped the registry for - // project kernels was meant to prevent cross-project leakage at - // the LIST level — but for a single-item lookup the kernel's own - // `engine.registry` is project-local (each ObjectQL instance has - // its own SchemaRegistry), so reading from it is safe and - // necessary. Without this, project-kernel callers of - // `GET /api/v1/meta/object/` 404 even though the object is - // registered and visible via the list endpoint. - // [#22877] A bundled type's name addresses ONE member (ruling A, Q2 i): - // the selector's, else the CANONICAL one. Step 1 read only that - // member's stored row; when there is none, the member is the code - // layer's — the artifact that ships it — and then the registry's, asked - // for that member. The registry's bare-name read answers the canonical - // member wherever it sits (its own [#22877] note), and still answers a - // bundle that ships no canonical member at all; a named member that no - // layer holds answers nothing, so the door answers it as absent. - if (item === undefined && wantedMember !== undefined) { - const artifact = this.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember); - if (artifact !== undefined && isWantedMember(artifact)) item = artifact; - } - if (item === undefined) { - item = this.engine.registry.getItem(request.type, request.name, request.packageId, member); - if (item === undefined) { - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) item = this.engine.registry.getItem(alt, request.name, request.packageId, member); - } - // [#22877] A registry that does not narrow by member (a host - // double, an older registry) can answer another member of the - // bundle for a named one; that answer is not this member. - if (member !== undefined && item !== undefined && !isWantedMember(item)) item = undefined; - // [#20946] The registry half — see `storedRowDeclined` above. - // `getItem` answers the bare slot first, and for a shipped flow name - // that slot holds the hydrated stored row, which is not one of the - // loader's entries; the loader's entry is the one the list serves. - // [#21922] Flow-only: a code-defined datasource's definition is not - // in the registry, and step 2 has already served it. - if (this.isStoredFlowEntryOfShippedName(request.type, item)) { - item = this.lookupArtifactItem(request.type, request.name, request.packageId); - } - // [#28802, #28390 ruling A] A bundle member on a base's address - // (the package named, else the one whose content a read naming - // none serves) is one that address serves: the registry's last - // tier, the first composite match across packages (ADR-0048 §3.3), - // no longer answers a dependent's member under the base's name. - const memberAddress = request.packageId || (typeof contentPackageId === 'string' ? contentPackageId : undefined); - if (item !== undefined && wantedMember !== undefined && memberAddress !== undefined) { - item = this.registryMemberServedAt(request.type, request.name, memberAddress, item, member); - } - } - - // [#5840] The MetadataService half of the #5532 rule, and the last - // moment it can be applied. The cached-read wrapper below this method - // documents its 404 as "reaching here now means a real miss — - // `getMetaItem` throws 503 rather than answering `undefined` when the - // store could not be read". That was true of the overlay read only: a - // metadata database the LOADERS could not reach was warn-logged inside - // `MetadataManager` and arrived at step 2 as a plain `undefined`, so - // the outage was served as `404 RESOURCE_NOT_FOUND` — a claim about - // what the author declared, made from a read that never happened. - // - // Deliberately narrow, and deliberately after step 3: a registry hit is - // a real declaration, so the answer contains no false claim and is - // served exactly as before (possibly staler than the service copy — the - // pre-existing ordering trade-off, not this issue's). Only when the - // WHOLE chain resolved nothing does the degraded read change anything, - // because only then would this method answer "no such item". - if (item === undefined && serviceDegraded?.degraded) { - this.throwMetadataServiceUnavailable(serviceDegraded.errors); - } - - // Merge registered navigation contributions into a served app - // (ADR-0029 D7) — parity with the getMetaItems list path so a - // single-app fetch (GET /meta/app/) also sees the contributed - // menu entries, not just the empty group-anchor shell. - if ((request.type === 'app' || request.type === 'apps') && item) { - item = this.engine.registry.applyNavContributions(item); - } - - // ADR-0010 §3.3 — artifact-level protection (lock/packageId) always - // wins over any overlay row. The metadata service may return a - // persisted overlay copy that pre-dates the artifact's `_lock` - // declaration; we must consult the in-memory artifact registry - // directly and let its protection envelope override. - // ADR-0048 — scope the artifact lookup to the requested package so a - // same-name collision grafts the OWNING package's provenance envelope - // (`_packageId`), not whichever package registered first. - // [#22024] …and, naming no package, to the package of the item served - // above ({@link envelopePackageId}, the list's own rule), so the answer - // wears the envelope of the package whose row, expansion or item it - // serves. Before, a read naming no package took the first-registered - // package's envelope, so a name two packages ship answered one - // package's stored copy under the other package's `_packageId`. - // [#23074] …and at the served item's own bundle member, whose artifact - // alone may lend it an envelope ({@link mergeArtifactProtection}): a - // member the package does not ship is served as the environment's own. - const artifactItem = this.lookupArtifactItem( - request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item), - ); - // [#21738] The lock is the one item-lock resolution's, over the layers - // this read resolved from its address: [#21803] every installed - // package that ships the name ({@link artifactLockLayerAt}), never the - // one artifact the content above came from, and [#21761] the rows in - // scope. - const itemLock = resolveItemLock({ - artifact: this.artifactLockLayerAt({ - type: request.type, - name: request.name, - packageId: request.packageId, - }), - overlay: overlayLockLayer, - }); - // [#21761, #21803] …and the body carries that answer's lock family, so - // it states the lock the envelope below reports. [#24535] On the - // execution face `decorated` is that governed body undecorated. - let decorated = serveItem( - this.governServedObject( - request.type, - mergeArtifactProtection(request.type, item, artifactItem, itemLock), - ), - ); - // ADR-0047 — list views additionally get reference-integrity - // diagnostics (userFilters/tabs fields must exist on the source - // object, kanban groupBy must be select-like). Zod cannot see - // across documents; merge the cross-document errors into the - // same `_diagnostics` envelope. Defensive: a failed lookup must - // never break a read. [#24535] Served only: a serving decoration. - if (!execution && (request.type === 'view' || request.type === 'views') && decorated && typeof decorated === 'object') { - try { - const viewDoc = decorated as Record; - const sourceObject = viewDoc?.object - ?? viewDoc?.data?.object - ?? viewDoc?.objectName - ?? viewDoc?.list?.data?.object; - const objectDef = typeof sourceObject === 'string' - ? this.engine.registry.getObject(sourceObject) - : undefined; - if (objectDef) { - const refs = computeViewReferenceDiagnostics(viewDoc, objectDef as any); - if (!refs.valid) { - const prior = viewDoc._diagnostics; - decorated = { - ...viewDoc, - _diagnostics: { - valid: false, - errors: [ - ...(prior && prior.valid === false && Array.isArray(prior.errors) ? prior.errors : []), - ...(refs.errors ?? []), - ], - }, - } as typeof decorated; - } - } - } catch { /* reference diagnostics are best-effort */ } - } - // [#29196] …and the lint's form-layout warnings, after the reference - // merge above (which rebuilds `_diagnostics` from its errors alone). - decorated = await serveFormLayout(decorated); - // ADR-0010 — surface lock/provenance flags so Studio can render - // the correct affordances without a second round trip. [#21670] They - // report the write doors' verdicts — see {@link servedLockState}. - // [#21738] The lock is the one item-lock resolution's (above), over the - // layers this read resolved — never the served document's `_lock`. - const artifactBacked = this.isArtifactBacked(request.type, request.name); - const lockState = this.servedLockState( - request.type, request.name, decorated, artifactBacked, itemLock, storedRowServed, - ); - // [#23316] Beside `lock`, never inside `item`: the automation engine's - // verdict on this flow ({@link resolveFlowRegistrationReader}), for an - // item this read serves. The draft returns above carry none — a - // pending body is not what the engine holds. [#24535] Served only, as - // the list's `registrations` are: the execution face carries none. - const readRegistration = !execution && decorated !== undefined && decorated !== null - ? this.resolveFlowRegistrationReader(request.type) - : undefined; - // [#27842] …and, on an `object` read, the names in `item.actions` the - // activation ledger switches off ({@link resolveDisabledActionsReader}), - // beside the item on the same terms: served face only, none on the - // draft returns above, `item` untouched. - const readDisabledActions = !execution && decorated !== undefined && decorated !== null - ? this.resolveDisabledActionsReader(request.type) - : undefined; - // [#28001] …and the objects a delete of one of its records can cascade - // into ({@link resolveCascadeDeleteObjects}), on the same terms. - const cascadeDeleteObjects = !execution && decorated !== undefined && decorated !== null - ? this.resolveCascadeDeleteObjects(request.type, request.name) - : undefined; - return { - type: request.type, - name: request.name, - item: decorated, - lock: lockState.lock, - ...(lockState.lockReason !== undefined ? { lockReason: lockState.lockReason } : {}), - ...(lockState.lockSource !== undefined ? { lockSource: lockState.lockSource } : {}), - ...(lockState.lockDocsUrl !== undefined ? { lockDocsUrl: lockState.lockDocsUrl } : {}), - ...(lockState.provenance !== undefined ? { provenance: lockState.provenance } : {}), - ...(lockState.packageId !== undefined ? { packageId: lockState.packageId } : {}), - ...(lockState.packageVersion !== undefined ? { packageVersion: lockState.packageVersion } : {}), - editable: lockState.editable, - deletable: lockState.deletable, - resettable: lockState.resettable, - // [#22114] The active row's version token ({@link readVersionToken}). - // No row was found at any candidate this read consults — which - // include the save's own address — so `null` needs no second read. - // ABSENT on a `previewDrafts` read: it serves a pending draft where - // one exists and the active item where none does, so no single - // lifecycle's token describes it, and it is a render path — an - // editor reads `state: 'draft'` or the plain read. - ...(request.previewDrafts - ? {} - : { - version: storedRowServed - ? await this.readVersionToken({ ...request, member }, 'active', packagelessBinding) - : null, - }), - ...(readRegistration ? { registration: readRegistration(request.name) } : {}), - ...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}), - ...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}), - }; + // [#25467 E1] The body moved to `protocol-meta-item-reads.ts`; it reaches this class through `protocol-host.ts`. + return readMetaItem(protocolHost(this), request, opts); } /** @@ -5375,12 +4708,8 @@ export class ObjectStackProtocolImplementation implements /** The bundle member the layered read addresses ({@link resolveBundleMemberSelector}); omitted, the canonical one. */ member?: string; }): Promise<(Error & { code: 'RESOURCE_CONFLICT'; status: 409 }) | undefined> { - const { type, name } = canonicalizeMetaRequestType(request); - const member = resolveBundleMemberSelector(type, request.member); - const address = await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member); - return address.candidates.length > 1 - ? packagelessAddressAmbiguityRefusal('layered read', type, name, address.candidates) - : undefined; + // [#25467 E1] The body moved to `protocol-meta-layered-reads.ts`; it reaches this class through `protocol-host.ts`. + return packagelessLayeredReadRefusal(protocolHost(this), request); } /** @@ -5472,459 +4801,8 @@ export class ObjectStackProtocolImplementation implements deletable: boolean; resettable: boolean; }> { - // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The - // three-layer diagnostic must answer for ONE namespace, or `code` and - // `overlay` can be read from two. - request = canonicalizeMetaRequestType(request); - // [#7557, #24097] A by-name API face of its own (`?layers=true`), which - // reads its layers without going through {@link getMetaItem}, so it - // refuses a disabled package's object itself: one answer per door. - if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) { - throw objectPackageDisabledError(request.name); - } - // [#26699] Ruling 6116165299 (Q2 i), extended to this read by the - // option-A ruling on #26649: ONE member of a bundled type, the - // selector's or else the canonical one, read exactly as - // {@link getMetaItem} reads it. Every layer below is that member's or - // nothing — the code layer's service copy, artifact and registry entry, - // and the stored row — so a named member nothing holds answers no - // layer, which the door answers as an absent item, ⛔ never with the - // canonical member's layers. - const member = resolveBundleMemberSelector(request.type, request.member); - const wantedMember = bundleMemberDiscriminator(request.type, member); - const isWantedMember = (candidate: unknown): boolean => - wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember; - - // [ADR-0131 D6] Environment → code: the overlay layer is the - // environment's stored row; no organization's row is read. - - // ── code layer: MetadataService.get + registry, BYPASSING overlay ── - let code: unknown | null = null; - let codeDegraded: { degraded: boolean; errors: string[] } | undefined; - try { - // ADR-0048 — package-scope the code layer so a same-name - // collision resolves to the requested package's artifact. - const fromService = await this.readItemFromMetadataService( - request.type, - request.name, - request.packageId, - ); - if (fromService.data !== undefined && fromService.data !== null) { - // [#26699] …when it is the member this read addresses, as in - // {@link getMetaItem}: the service keys a type's items by name - // alone, so for a bundle it holds whichever member it - // registered last. Another member's copy is no layer of this - // one; the artifact and registry lookups below ask for it. - // [#7556] The CODE layer of an object is D9.6's "owner's - // declaration with its extenders folded on", so the - // MetadataService copy is its base, not the layer itself. - // `effective` is `overlay ?? code`, so an object with no - // overlay row — the ordinary shape — is corrected by this - // single fold on both layers the diagnostic reports. - if (isWantedMember(fromService.data)) { - code = this.foldObjectExtendersFromRegistry( - request.type, request.name, fromService.data, - ); - } - } else if (fromService.degraded) { - // [#5840] Kept, not swallowed — acted on after the registry - // fallback below, which may still produce a real code layer. - codeDegraded = fromService; - } - } catch { - // ignore - } - if (code === null) { - // Prefer the artifact-only lookup so an overlay row hydrated - // into the registry's plain key can't masquerade as the "code - // default" layer; fall back to getItem for runtime-only items. - // - // [#21059] …and "runtime-only" is held to what it says. The plain - // key is one slot for two populations: an item registered at - // runtime with no package, and a stored row the hydrator put there - // ({@link hydrateOverlayIntoRegistry}, the boot `object` limb of - // {@link loadMetaFromDb}). For a name no package ships the - // artifact lookup misses, so `getItem` answered the hydrated row - // and the code layer became the stored body — but only once a - // hydration had run, so the same read answered `code: null` before - // it. The spec's layer 1 is "`null` when no artifact ships this - // item (it exists only as an overlay)", and #5707 / #5840 allow a - // layer only from a read that happened: a stored row is the overlay - // read below, never an artifact read. - // - // The discriminator is the tenant marker the hydrator ALREADY - // writes on every row it registers — ADR-0010 `_provenance: 'org'`, - // read through {@link isTenantAuthored}. ⛔ No new marker. A body - // that carries package-provenance stamps under a name no package - // ships is the same row: the hydrator restates its authorship over - // whatever its bytes claim, and a stamp is not an artifact read, so - // the envelope's provenance fields below read this item's code - // layer as `null` too (triage's ruling, overturnable by the - // maintainer); its lock reads no code layer at all ([#21738], the - // item-lock resolution below). A - // runtime-registered item with no package carries no tenant marker - // and keeps its code layer. - // - // - // [#26699] Each lookup is asked for the addressed member, exactly - // as {@link getMetaItem}'s are: the artifact at the member (the - // canonical one when none was named), and the registry for the - // member named. The registry's bare-name read still answers a - // bundle that ships no canonical member; a registry that does not - // narrow by member can answer another one for a NAMED member, and - // that answer is not this member's layer. - const runtimeOnly = (item: unknown): unknown => - item !== undefined && isTenantAuthored(item) ? undefined : item; - const namedMemberOnly = (item: unknown): unknown => - member !== undefined && item !== undefined && !isWantedMember(item) ? undefined : item; - const artifact = this.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember); - let regItem = (artifact !== undefined && isWantedMember(artifact) ? artifact : undefined) - ?? namedMemberOnly(runtimeOnly(this.engine.registry.getItem(request.type, request.name, request.packageId, member))); - if (regItem === undefined) { - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) regItem = namedMemberOnly(runtimeOnly(this.engine.registry.getItem(alt, request.name, request.packageId, member))); - } - if (regItem !== undefined) code = regItem; - } - - // [#5840] The code half of the rule #5707 wrote for the overlay half, - // eleven lines below. `code: null` is not a shrug — this method states - // it positively ("no packaged/code-layer definition exists"), and the - // response then DERIVES from it: `effective` and the envelope's - // provenance fields. (It used to derive the lock too, from - // `code ?? overlay`, so an item whose code layer declared `_lock: - // 'full'` was rendered `editable: true, deletable: true` when the read - // that would have found that lock failed. [#21738] The lock is now the - // item-lock resolution's, over the registry's artifact lookup, which - // this read cannot lose.) An availability failure stated as an - // authorship fact is precisely what ADR-0110 D3 forbids, and the - // overlay half of this very method already refuses to do it — the two - // halves were asymmetric only because the loader failure was invisible - // on this side. - // - // Same narrow shape as the overlay half: the benign "nothing there" - // still returns `code: null` normally (a clean miss is not degraded), - // and a registry hit above is a real code layer, so this fires only - // when the null would otherwise be an unfounded authorship claim. - if (code === null && codeDegraded?.degraded) { - this.throwMetadataServiceUnavailable(codeDegraded.errors); - } - - // ── overlay layer: the environment's sys_metadata row (ADR-0131 D6) ── - let overlay: unknown | null = null; - let overlayScope: 'env' | null = null; - // [#28412] The stored row `overlay` was read from, when the served-row - // resolution read one: what {@link declinesStoredRow} judges a shipped - // flow name's declared override on. - let overlayRow: ServedOverrideRow | undefined; - // [#21761] The `overlay` layer of the one item-lock resolution below, - // selected from this read's address as in {@link getMetaItem}: the - // strictest lock among the item's stored rows in scope, never only the - // row this layer reports. - let overlayLockLayer: unknown; - // [#23291] Naming no package, the overlay comes from the address - // {@link getMetaItem} serves its content from - // ({@link packagelessReadAddress}), so `effective` is what that read - // returns and `/published` serves the same document. Content only: the - // lock stays at the request's address, as the `_lock` gate judges it. - // Asked outside the `try` below: its own failed read already answers - // the #5532 envelope, with the driver's error as `cause`. - const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name, member); - try { - // ADR-0048 prefer-local. [#21716] The ONE served-row resolution - // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. - // [#26699] At the addressed member, as that read asks it. - // [#28409, ADR-0146 D8 item 2] …with the package's declared - // override first, as {@link getMetaItem} serves it: `overlay` is - // then the `.custom` item, `code` the base's document and - // `effective` the override (the base's document stays reachable - // here, ADR-0146 "Decided here" item 5). - let served: { row: any; overrideOf?: string } | undefined = await this.findServedOverlayRow({ - type: request.type, - name: request.name, - state: 'active', - ...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}), - ...memberOption(member), - servedOverride: true, - }); - // [#28830, #28390 ruling A] The pair's own added member is the - // overlay on the base's address, as the by-name read serves it - // ({@link findPairAddedMemberRow}); another package's never is. - if (!served && wantedMember !== undefined && typeof contentPackageId === 'string') { - const row = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember); - if (row) served = { row }; - } - overlayLockLayer = await this.overlayLockLayerAt({ - type: request.type, - name: request.name, - packageId: request.packageId, - }, { otherSpelling: true }); - if (served) { - const rec = served.row; - overlayRow = rec as ServedOverrideRow; - overlay = this.convertStoredItem( - String(rec.type ?? request.type), - storedRowDocument(rec), - ); - overlayScope = 'env'; - } - } catch (error) { - // [#5707] The same rule as the four overlay reads in - // `getMetaItems` / `getMetaItem` (#5532), on the one overlay read - // PR #5705 deliberately did not reach. - // - // Swallowing here does not answer 404 — it answers something this - // method states positively in THREE fields at once: `overlay: null` - // ("nothing was ever customised"), `overlayScope: null` ("no row - // is stored"), and `effective = code` ("what runs today is the - // packaged artifact, verbatim"). The whole point of the layered - // read is to show an author what they changed; during an outage it - // told them they had changed nothing, which is the #5532 error — - // an availability failure reported as an authorship fact — landing - // in the diff view instead of on a 404. - // - // The benign case is unchanged and is why this is not a bare - // rethrow: an unprovisioned `sys_metadata` genuinely holds no - // overlay row, so `overlay: null` / `effective = code` IS the truth - // and first boot still renders the code layer. - // See {@link rethrowUnlessMetadataStoreUnprovisioned}. - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - - // ── [#21442] A row-less name a stored view container expands ── - // - // No row of this name was read above. When a stored view container in - // this caller's scope expands the name ({@link resolveRowlessExpandedView}, - // the list read's own selection and expansion), `effective` is the - // expanded item — what {@link getMetaItem} answers for the name — and - // the layers say where it came from in the fields that already say it: - // `overlay` is the container's own stored row, the one stored layer - // behind this name (its `name` is the container's, its `_packageId` the - // package its row is bound to), and `overlayScope` `'env'`. `code` keeps its own read: the item a package ships - // under this name (a tenant overlay of that package's container), else - // `null`. - let expandedFrom: RowlessExpandedView | undefined; - if (overlay === null) { - expandedFrom = await this.resolveRowlessExpandedView(request); - if (expandedFrom !== undefined) { - overlay = expandedFrom.container.data; - overlayScope = 'env'; - } - } - - // ── [#28409, ADR-0146 D8; N-09 rider gap 3] An ORPHANED row of the name ── - // - // Nothing stored is served at the address of a name its package X - // ships (`code` is X's document), and yet a row of the name is stored in X's pair - // (`.custom`) without the override declaration, or in a package - // that depends on X (X is in its manifest's dependency closure, - // {@link resolveWritePackageScope}). The shape a base upgrade leaves: - // the dependent or the pair saved the name before a later version of - // X shipped it. Since the effective read serves X's document under - // X's name (D8 item 1), that row is no longer served there, and the - // env-wide list lists the name once, as X's. It is still the - // package's own item, under its own address. The layered read of X's - // name REPORTS it, so it is never silently dropped: `overlay` holds - // the row as stored, a shadowed layer beside the effective one, as for - // a shipped flow name's stored row (#21002), and `effective` stays the - // code layer the by-name read serves. The flags below are the address's - // as before: the orphan is not this address's row. - // - // A row of a package outside X's closure is not an orphan of X's name: - // it is that package's own item (ADR-0048 §3.3), and not reported here. - // With several such rows, the pair's is reported, else the first - // package's in id order. - let orphaned = false; - if (overlay === null && code !== null && typeof contentPackageId === 'string' && contentPackageId !== '') { - const base = contentPackageId; - let rows: readonly StoredOverlayRow[] = []; - try { - rows = await this.readItemActiveRows(request.type, request.name); - } catch (error) { - // [#5707] The same rule as the overlay read above: a read that - // did not happen is not an item with no orphan. - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - const memberWhere = overlayMemberWhere(request.type, member); - const boundTo = (row: StoredOverlayRow): string | undefined => boundPackageOf(row.package_id); - const orphan = rows - .filter((row) => rowMatchesOverlayWhere(row, memberWhere)) - .filter((row) => { - const owner = boundTo(row); - if (owner === undefined || owner === base) return false; - if (isServedOverrideRowOf(row, base)) return false; - const pair = customizationPackageId(base); - if (pair.ok && pair.value === owner) return true; - return this.resolveWritePackageScope(owner)?.dependencies.includes(base) === true; - }) - .sort((a, b) => { - const pair = customizationPackageId(base); - const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1); - return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b))); - })[0]; - if (orphan !== undefined) { - overlay = this.convertStoredItem( - String((orphan as { type?: unknown }).type ?? request.type), - storedRowDocument(orphan), - ); - overlayScope = 'env'; - orphaned = true; - } - } - - // [#4513] `effective` is documented above as "what `getMetaItem` would - // return", and the response's `_diagnostics` is computed from it — so it - // carries the same audit-family governance that read now applies, or the - // sentence stops being true the moment the overlay declares a writable - // `created_at`. `code` and `overlay` are deliberately left RAW: they are - // the diagnostic's whole point (what the package shipped vs what was - // customised), and a Studio diff showing `code`'s declaration next to - // `effective`'s governed value is the platform override made visible, - // not a defect. - // [#8027] …and `effective` is "what `getMetaItem` would return", so when - // the overlay wins it is that read's BASE LAYER, resolved the same way: - // D9.2's base-plus-extenders. Without this the single `?layers=true` - // response contradicted itself — `code` carried the extension fields - // (#7556 folds it) and `effective` did not, with the `overlay` layer - // showing a customisation that explained none of the difference. - // - // ⛔ The fold lands on the EFFECTIVE base only. `overlay` stays the row - // the tenant actually stored: a code-declared extension is not a tenant - // customisation, and #7556 drew that boundary deliberately (the same - // reason `governServedItem` is called here and never on `overlay`). - // - // ── [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ── - // - // `flow` is Regime C: the packaged base is locked, "⛔ Never silent - // override, never an overlay read path". For a flow name the loader's - // set holds, the flattened view (#20913) and {@link getMetaItem} - // (#20946) serve the loader's body, and a stored row of that name is - // neither merged into the package's slot nor lets it stand in for it. - // `effective` is "what `getMetaItem` would return", so for such a name - // it is the CODE layer, decided by the SAME stored-row predicate, - // {@link isShippedFlowName}, judged by NAME — ⛔ no fourth precedence - // path of this method's own. Overlay-wins here served the stored body - // as the effective layer while the lock and provenance flags (resolved - // from `code` first, below) named the package. - // - // The stored row is still REPORTED: `overlay` / `overlayScope` keep the - // row as stored, a shadowed layer of its own scope beside the effective - // one, never the effective layer itself. The code layer needs no - // registry-half call ({@link isStoredFlowEntryOfShippedName}): it reads - // the loader's set (`lookupArtifactItem`, blind to tenant-authored rows) - // before the registry's bare slot, and a shipped name is by definition - // one that set holds. Every other type, and a flow name no managed - // package ships, keeps overlay-wins. What becomes of the stored rows - // themselves (keep, refuse, migrate) is not decided here. - // - // [#21922] The same holds for a code-defined DATASOURCE name, through - // the one predicate both name classes share ({@link declinesStoredRow}): - // `getMetaItem` serves the MetadataService's code definition, so that is - // `effective` here (the code layer above reads the MetadataService - // first), and the stored row stays reported in `overlay` — the residue - // the `/meta` DELETE removes as repair. - // - // [#21442] A name a stored container expands (above) takes the expanded - // item as its effective layer: the container row in `overlay` is the - // layer it derives from, not the value the by-name read serves. - // [#28409] …and an orphaned row (above) is a shadowed layer of the - // same kind: `effective` is the code layer the by-name read serves. - const effectiveBase: unknown | null = expandedFrom !== undefined - ? expandedFrom.item - : overlay !== null && !orphaned && !this.declinesStoredRow(request.type, request.name, overlayRow) - ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) - : code; - // [#21738] The lock is the one item-lock resolution's — the - // `getMetaItem` call over [#21803] every installed package that ships - // the name ({@link artifactLockLayerAt}) and [#21761] the rows in - // scope for its address — never `code ?? overlay`, which took the code - // layer's (absent) lock over a stored row's `_lock`. A row-less name a - // stored container expands contributes no `overlay` layer: the - // container's row is not this name's row, and the `_lock` gate does not - // read it. The provenance fields still come from the layer the - // response reports first. - const itemLock = resolveItemLock({ - artifact: this.artifactLockLayerAt({ - type: request.type, - name: request.name, - packageId: request.packageId, - }), - overlay: overlayLockLayer, - }); - // [#21761, #21803] `effective` is what {@link getMetaItem} would - // return, and that read's body carries the resolution's lock family - // (the binding row's or the binding package's, which need not be the - // layer `effective` was taken from). So this one does too. `code` and - // `overlay` stay the layers as shipped and as stored. - const effective: unknown | null = withItemLockFamily( - this.governServedObject(request.type, effectiveBase), - itemLock, - ); - - const _diagnostics = - effective !== null && effective !== undefined - ? computeMetadataDiagnostics(request.type, effective) - : undefined; - - // ADR-0010 — surface lock/provenance flags so the Studio editor - // can render the correct affordances without a second round trip. - const artifactBacked = this.isArtifactBacked(request.type, request.name); - // [#21670] …joined with the locked-packaged-base verdict the write - // doors answer — the same derivation `getMetaItem` publishes. - const lockState = this.servedLockState( - request.type, request.name, code ?? overlay ?? {}, artifactBacked, itemLock, - overlay !== null && overlay !== undefined && !orphaned, - ); - - // [#8154] The per-type credential redaction, on the ONE read exit - // `decorateMetadataItem` does not reach — this method never calls it - // (it computes `_diagnostics` directly, above) and serves its three - // layers raw. Measured on `origin/main` before this change: a legacy - // `datasource` row came back with `config.password` in cleartext in - // BOTH `overlay` and `effective`, so a hook that covered only the - // decorated exits would have left this door open while the report said - // "the read path is closed" — the exact shape this card exists to end. - // - // ⚠️ ALL THREE LAYERS, `code` and `overlay` included, and that is a - // reading of ANOTHER lane's ruling rather than an inference from this - // one. #7556 keeps `code` / `overlay` deliberately RAW — ungoverned, - // uninjected, unfolded — so that a Studio diff shows what the tenant - // actually customised and nothing else. That reason does not extend to - // credentials, and redacting the key on BOTH sides leaves the diff - // itself unchanged: the key is absent from both layers, so "what was - // customised" reads exactly as before. Stated here, and in the PR body, - // so `domain:engine-core` / `domain:spec` reviewers can contest it - // rather than discover it. ⛔ It is NOT a licence to fold, govern or - // inject on these layers — redaction subtracts, and only a credential. - // - // Placed AFTER `_diagnostics` and AFTER `servedLockState`, both of - // which must read the raw bodies: the diagnostics ordering is the - // migration-inventory badge (see `decorateMetadataItem`), and a lock - // resolved from a redacted body would be a lock resolved from a - // document this method is not serving. - const servedCode = redactMetadataItem(request.type, code); - const servedOverlay = redactMetadataItem(request.type, overlay); - const servedEffective = redactMetadataItem(request.type, effective); - - return { - type: request.type, - name: request.name, - code: servedCode, - overlay: servedOverlay, - overlayScope, - effective: servedEffective, - ...(_diagnostics ? { _diagnostics } : {}), - lock: lockState.lock, - ...(lockState.lockReason !== undefined ? { lockReason: lockState.lockReason } : {}), - ...(lockState.lockSource !== undefined ? { lockSource: lockState.lockSource } : {}), - ...(lockState.lockDocsUrl !== undefined ? { lockDocsUrl: lockState.lockDocsUrl } : {}), - ...(lockState.provenance !== undefined ? { provenance: lockState.provenance } : {}), - ...(lockState.packageId !== undefined ? { packageId: lockState.packageId } : {}), - ...(lockState.packageVersion !== undefined ? { packageVersion: lockState.packageVersion } : {}), - editable: lockState.editable, - deletable: lockState.deletable, - resettable: lockState.resettable, - }; + // [#25467 E1] The body moved to `protocol-meta-layered-reads.ts`; it reaches this class through `protocol-host.ts`. + return getMetaItemLayered(protocolHost(this), request); } /** From 94d624af569f4cf9332fbb14dca4297fe9914c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:32:55 +0000 Subject: [PATCH 2/3] test(metadata-protocol): the lookupArtifactItem call-site pin reads the item and layered reads where their bodies now stand Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- ...ocol.lookup-artifact-item-call-sites.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index 00509081393..5580d893b9c 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -71,6 +71,9 @@ const BINDINGS_FILE = 'protocol-package-bindings.ts'; const BINDINGS = readFileSync(fileURLToPath(new URL(`./${BINDINGS_FILE}`, import.meta.url)), 'utf8'); /** [#25467 E2 cut 1a] The module the list read's body (`readFlattenedMetaItems`) moved to. */ const LIST_READS_FILE = 'protocol-meta-list-reads.ts'; +/** [#25467 E2 cut 1b] The modules the item read's body (`readMetaItem`) and the layered read's body (`getMetaItemLayered`) moved to. */ +const ITEM_READS_FILE = 'protocol-meta-item-reads.ts'; +const LAYERED_READS_FILE = 'protocol-meta-layered-reads.ts'; /** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */ const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(|^ {4}(static) \{$/; @@ -121,15 +124,15 @@ const SITES: ReadonlyArray, type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition', why: '[#25467 cut 15] the definition\'s body, the function the class member delegates to' }, - { member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope', + { file: ITEM_READS_FILE, member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope', why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' }, { file: LIST_READS_FILE, member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope', why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares; [#23074] at each item\'s own bundle member' }, - { member: 'readMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', + { file: ITEM_READS_FILE, member: 'readMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' }, - { member: 'readMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', + { file: ITEM_READS_FILE, member: 'readMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', why: '[#22877] a bundled type\'s addressed member (the selector\'s, else the canonical one) when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' }, - { member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', + { file: LAYERED_READS_FILE, member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer; [#26699] at the addressed member (the selector\'s, else the canonical one)' }, { member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no', why: 'the packaged base the translators compare a served body against: nothing of it is grafted onto the body' }, @@ -230,7 +233,11 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo // imports that definition rather than carrying a copy of its own. expect(BINDINGS).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/); expect(SOURCE).not.toMatch(/\nfunction envelopePackageId\(/); - expect(SOURCE).toMatch(/\nimport \{[^}]*\benvelopePackageId,[^}]*\} from '\.\/protocol-package-bindings\.js';/); + // [#25467 E2 cut 1b] The item read's body moved too, so neither door stands in + // protocol.ts any more: its module imports the same one definition. + const itemReads = MODULE_SOURCES.get(ITEM_READS_FILE)!; + expect(itemReads).not.toMatch(/\nfunction envelopePackageId\(/); + expect(itemReads).toMatch(/\nimport \{[^}]*\benvelopePackageId,[^}]*\} from '\.\/protocol-package-bindings\.js';/); // [#25467 E2 cut 1a] The list read's body moved: its envelope line is read // where it now stands, and that module imports the same one definition. const listReads = MODULE_SOURCES.get(LIST_READS_FILE)!; From 3912b131ffced8c309325b9fa42caa09c59116de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:33:53 +0000 Subject: [PATCH 3/3] chore(changeset): metadata-protocol patch for the item and layered reads move Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude --- .changeset/25467-e2-1b-metadata-item-layered-reads.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/25467-e2-1b-metadata-item-layered-reads.md diff --git a/.changeset/25467-e2-1b-metadata-item-layered-reads.md b/.changeset/25467-e2-1b-metadata-item-layered-reads.md new file mode 100644 index 00000000000..b5c7a4a01ff --- /dev/null +++ b/.changeset/25467-e2-1b-metadata-item-layered-reads.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the metadata item reads and the layered read move out of `protocol.ts` through the protocol host + +Clause-②: no +Metadata-protocol: none + +No behaviour change. The bodies of `getMetaItem`, `getMetaItemForExecution`, `readMetaItem`, `packagelessLayeredReadRefusal` and `getMetaItemLayered` now live in `protocol-meta-item-reads.ts` and `protocol-meta-layered-reads.ts`, reached through the per-instance host (`protocol-host.ts`). Each method keeps its docblock and signature on `ObjectStackProtocolImplementation`, and each moved body differs from the method body only by `this.` reading `host.`. The published `index.d.ts` (ESM and CJS) is byte-identical to the one `main` builds before this change. Nothing to migrate.