From 760ec905cac09aea1c21038b9cad2ed2c60790ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:20:01 +0000 Subject: [PATCH 1/5] wip(metadata-protocol): the by-name read resolves a row-less name a stored view container expands Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 467 +++++++++++++++------ 1 file changed, 342 insertions(+), 125 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 478daa416f..ad70a9e742 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4897,6 +4897,29 @@ function isNonCanonicalStoredType(type: string): boolean { return canonicalMetaType(type) !== type && (PLURAL_TO_SINGULAR[type] ?? type) === type; } +/** + * [#21442] A stored `sys_metadata` row as the list read parses it: its own + * name, its body (stored-row conversions replayed), and the package and + * organization it is bound to (`organizationId: null` for an environment-wide + * row). + */ +interface StoredOverlayEntry { + name: string; + data: any; + packageId: string | undefined; + organizationId: string | null; +} + +/** + * [#21442] A row-less view name a stored view container expands: the item the + * list read serves under the name, and the stored container row it derives + * from. + */ +interface RowlessExpandedView { + item: Record; + container: StoredOverlayEntry; +} + /** * Implements the per-domain contracts this class ACTUALLY provides (ADR-0076 * D10 — the facade never implemented the other domains; those live in their @@ -8225,126 +8248,16 @@ export class ObjectStackProtocolImplementation implements // (when an active org is provided) and env-wide (organization_id IS NULL) // overlays; org-scoped rows win on name collision. try { - const queryByOrg = async (oid: string | null): Promise => { - const whereClause: Record = { - type: request.type, - state: 'active', - organization_id: oid, - }; - if (packageId) whereClause.package_id = packageId; - let rs = await this.engine.find('sys_metadata', { where: whereClause }); - if ((!rs || rs.length === 0)) { - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) { - const altWhere: Record = { type: alt, state: 'active', organization_id: oid }; - if (packageId) altWhere.package_id = packageId; - rs = await this.engine.find('sys_metadata', { where: altWhere }); - } - } - return rs ?? []; - }; - // ── Leg D of #11633 (#11967): the cross-request overlay cache ── - // - // ⭐ The cache sits HERE, and its position IS the resolution of the - // SchemaRegistry-hydration trap #11633 §4 names. What is cached is - // the ROW SET — the value the two `queryByOrg` calls produce — - // never the merged answer below it. Everything downstream of this - // point still runs on every call, hit or miss: the overlay parse, - // the package-aware merge, `hydrateOverlayIntoRegistry`, the - // MetadataService merge, the disabled-package filter, the nav - // contributions, the decorations. A hit changes where the rows came - // from and nothing about what is done with them, so the read-side - // registry hydration cannot be skipped by one. - // - // ⛔ Do NOT move this below the merge. That is precisely the naive - // shape the trap describes, and it would additionally serve three - // mutable sources — the SchemaRegistry, the MetadataService and the - // artifact table — whose changes nothing in this key can observe. - // See `meta-overlay-cache.ts` for the measured four-source table. - // - // ⭐ The epoch reading is taken BEFORE the read, and it is this - // pre-read value that is stored with the rows. A write landing - // WHILE this read is in flight therefore moves the epoch past what - // the entry records, so the entry is already dead when it is - // written — the safe direction. Reading it afterwards would stamp - // pre-write rows with a post-write epoch and make that staleness - // permanent: the clear-then-repopulate-from-a-stale-read failure - // #11633 §7 pin 2 names. - const overlayCacheKey: MetaOverlayCacheKey = { - type: request.type, - packageId, - organizationId: orgId, - }; - const overlayCacheEpoch = readWriteEpoch(this.engine); - const overlayCacheTtlMs = metaOverlayCacheTtlMs(); - const overlayCacheNow = Date.now(); - const cachedRecords = readMetaOverlayCache( - this.engine, overlayCacheKey, overlayCacheEpoch, overlayCacheTtlMs, overlayCacheNow, - ); - - let records: any[]; - if (cachedRecords !== undefined) { - records = cachedRecords as any[]; - } else { - const envWideRecords = await queryByOrg(null); - const orgRecords = orgId ? await queryByOrg(orgId) : []; - // org-specific rows override env-wide rows on name collision. - // ADR-0048 (#1828) — key by (package, name), not bare name, so a - // package A row and a package B row of the same name do not - // collapse; org-over-env precedence still holds within each slot. - // - // [#7774] …and for a bundled type the slot is `(package, name, - // locale)`. Within ONE org this changes nothing — the store's own - // unique index is `(type, name, organization_id, package_id)`, so - // an org cannot hold two rows that differ only by body locale. - // Across the two tiers it can: an env-wide row and this org's row - // may customize DIFFERENT members of one bundle, and keying them - // together made the org's zh-CN row silently displace the - // env-wide en-US one. Precedence is unchanged where it was ever - // meaningful — an org row still overrides the env-wide row of the - // same member — and an undiscriminated type keeps a - // byte-identical key. - const mergedMap = new Map(); - const rowKey = (r: any): string => - metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); - for (const r of envWideRecords) mergedMap.set(rowKey(r), r); - for (const r of orgRecords) mergedMap.set(rowKey(r), r); - records = Array.from(mergedMap.values()); - // ⭐ An EMPTY row set is cached too, and that is the main point - // rather than an edge case: the empty result is what triggers the - // alt-type retry above, so "no overlay rows for this type" is the - // answer whose caching removes BOTH reads. #11633 §1 measured that - // an app whose objects are all code-authored pays the doubled read - // on every request; this is the line that stops it. - writeMetaOverlayCache( - this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, - ); - } + // [#21442] The row set this read consults — the overlay cache + // included — is {@link readActiveOverlayRows}, and each row is + // parsed by {@link storedOverlayEntries}. The by-name read selects + // the view containers it expands through the same two calls, so + // the two doors cannot disagree about which containers are in + // scope for one caller. + const records = await this.readActiveOverlayRows(request, orgId); if (records && records.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; - // Parse each overlay body once — replaying the stored-row - // conversion chain (#3903) so every consumer of this list sees - // the canonical protocol shape — and surface its persisted - // software-package binding so the sidebar package filter and - // provenance classification see overlay rows the way they see - // registry items. - const overlays = records.map((record) => { - const data = this.convertStoredItem( - String(record.type ?? request.type), - typeof record.metadata === 'string' - ? JSON.parse(record.metadata) - : record.metadata, - ) as any; - const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; - if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { - (data as any)._packageId = recPkg; - } - // [#6602] The row's own scope travels with its body. The - // merged set below is env-wide rows PLUS this org's rows, - // and the two are only distinguishable here, at the row. - const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; - return { data, packageId: recPkg, organizationId: recOrg }; - }); + const overlays = this.storedOverlayEntries(request, records); // ADR-0048 (#1828) — package-aware merge: a package-scoped row // overlays ONLY its own package's entry, so two installed @@ -8416,10 +8329,8 @@ export class ObjectStackProtocolImplementation implements for (const it of items as any[]) { if (it && typeof it === 'object' && typeof it.name === 'string') byName.set(it.name, it); } - for (const { data, packageId: recPkg } of overlays) { - for (const vi of this.expandRuntimeViewContainer(request.type, data, { packageId: recPkg })) { - byName.set(vi.name as string, vi); - } + for (const { item: vi } of this.expandStoredViewContainers(request.type, overlays)) { + byName.set(vi.name as string, vi); } items = Array.from(byName.values()); } @@ -8704,6 +8615,238 @@ export class ObjectStackProtocolImplementation implements }; } + /** + * [#21442] The active `sys_metadata` rows a read of `request.type` consults + * for one caller: the environment-wide rows, plus that organization's rows + * when `orgId` names one (an organization's row wins its slot), restricted + * to `request.packageId` when one is given. Moved here unchanged from + * {@link readFlattenedMetaItems}, the list read, so the by-name read + * ({@link resolveRowlessExpandedView}) selects the view containers it + * expands by this same rule — ⛔ never a second selection rule, which would + * be a second expansion rule. + * + * `orgId` arrives already gated ({@link organizationIdForMetaRead}). A read + * failure is thrown as the engine threw it; each caller applies the #5532 + * rule ({@link rethrowUnlessMetadataStoreUnprovisioned}). + */ + private async readActiveOverlayRows( + request: { type: string; packageId?: string }, + orgId: string | undefined, + ): Promise { + const { packageId } = request; + const queryByOrg = async (oid: string | null): Promise => { + const whereClause: Record = { + type: request.type, + state: 'active', + organization_id: oid, + }; + if (packageId) whereClause.package_id = packageId; + let rs = await this.engine.find('sys_metadata', { where: whereClause }); + if ((!rs || rs.length === 0)) { + const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; + if (alt) { + const altWhere: Record = { type: alt, state: 'active', organization_id: oid }; + if (packageId) altWhere.package_id = packageId; + rs = await this.engine.find('sys_metadata', { where: altWhere }); + } + } + return rs ?? []; + }; + // ── Leg D of #11633 (#11967): the cross-request overlay cache ── + // + // ⭐ The cache sits HERE, and its position IS the resolution of the + // SchemaRegistry-hydration trap #11633 §4 names. What is cached is + // the ROW SET — the value the two `queryByOrg` calls produce — + // never the merged answer below it. Everything downstream of this + // point still runs on every call, hit or miss: the overlay parse, + // the package-aware merge, `hydrateOverlayIntoRegistry`, the + // MetadataService merge, the disabled-package filter, the nav + // contributions, the decorations. A hit changes where the rows came + // from and nothing about what is done with them, so the read-side + // registry hydration cannot be skipped by one. + // + // ⛔ Do NOT move this below the merge. That is precisely the naive + // shape the trap describes, and it would additionally serve three + // mutable sources — the SchemaRegistry, the MetadataService and the + // artifact table — whose changes nothing in this key can observe. + // See `meta-overlay-cache.ts` for the measured four-source table. + // + // ⭐ The epoch reading is taken BEFORE the read, and it is this + // pre-read value that is stored with the rows. A write landing + // WHILE this read is in flight therefore moves the epoch past what + // the entry records, so the entry is already dead when it is + // written — the safe direction. Reading it afterwards would stamp + // pre-write rows with a post-write epoch and make that staleness + // permanent: the clear-then-repopulate-from-a-stale-read failure + // #11633 §7 pin 2 names. + const overlayCacheKey: MetaOverlayCacheKey = { + type: request.type, + packageId, + organizationId: orgId, + }; + const overlayCacheEpoch = readWriteEpoch(this.engine); + const overlayCacheTtlMs = metaOverlayCacheTtlMs(); + const overlayCacheNow = Date.now(); + const cachedRecords = readMetaOverlayCache( + this.engine, overlayCacheKey, overlayCacheEpoch, overlayCacheTtlMs, overlayCacheNow, + ); + + let records: any[]; + if (cachedRecords !== undefined) { + records = cachedRecords as any[]; + } else { + const envWideRecords = await queryByOrg(null); + const orgRecords = orgId ? await queryByOrg(orgId) : []; + // org-specific rows override env-wide rows on name collision. + // ADR-0048 (#1828) — key by (package, name), not bare name, so a + // package A row and a package B row of the same name do not + // collapse; org-over-env precedence still holds within each slot. + // + // [#7774] …and for a bundled type the slot is `(package, name, + // locale)`. Within ONE org this changes nothing — the store's own + // unique index is `(type, name, organization_id, package_id)`, so + // an org cannot hold two rows that differ only by body locale. + // Across the two tiers it can: an env-wide row and this org's row + // may customize DIFFERENT members of one bundle, and keying them + // together made the org's zh-CN row silently displace the + // env-wide en-US one. Precedence is unchanged where it was ever + // meaningful — an org row still overrides the env-wide row of the + // same member — and an undiscriminated type keeps a + // byte-identical key. + const mergedMap = new Map(); + const rowKey = (r: any): string => + metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); + for (const r of envWideRecords) mergedMap.set(rowKey(r), r); + for (const r of orgRecords) mergedMap.set(rowKey(r), r); + records = Array.from(mergedMap.values()); + // ⭐ An EMPTY row set is cached too, and that is the main point + // rather than an edge case: the empty result is what triggers the + // alt-type retry above, so "no overlay rows for this type" is the + // answer whose caching removes BOTH reads. #11633 §1 measured that + // an app whose objects are all code-authored pays the doubled read + // on every request; this is the line that stops it. + writeMetaOverlayCache( + this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, + ); + } + return records; + } + + /** + * [#21442] Each active row {@link readActiveOverlayRows} returned, parsed as + * the list read parses it: the body (stored-row conversions replayed), the + * package and the organization the row is bound to, and the row's own name. + * Moved here from {@link readFlattenedMetaItems} so the by-name read + * expands the very bodies the list read expands. + */ + private storedOverlayEntries( + request: { type: string }, + records: any[], + ): StoredOverlayEntry[] { + // Parse each overlay body once — replaying the stored-row + // conversion chain (#3903) so every consumer of this list sees + // the canonical protocol shape — and surface its persisted + // software-package binding so the sidebar package filter and + // provenance classification see overlay rows the way they see + // registry items. + return records.map((record) => { + const data = this.convertStoredItem( + String(record.type ?? request.type), + typeof record.metadata === 'string' + ? JSON.parse(record.metadata) + : record.metadata, + ) as any; + const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; + if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { + (data as any)._packageId = recPkg; + } + // [#6602] The row's own scope travels with its body. The + // merged row set is env-wide rows PLUS this org's rows, and + // the two are only distinguishable here, at the row. + const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; + return { name: String(record.name), data, packageId: recPkg, organizationId: recOrg }; + }); + } + + /** + * [#21442] Every item the stored view containers in `overlays` expand, in + * the order the list read upserts them by name (a later expansion of a + * name replaces an earlier one), each paired with the stored row it was + * expanded from. The one expansion pass both doors run: the list read + * serves the items, and {@link resolveRowlessExpandedView} also needs the + * row. Each container goes through {@link expandRuntimeViewContainer}, + * unchanged. + */ + private expandStoredViewContainers( + type: string, + overlays: readonly E[], + ): Array<{ item: Record; container: E }> { + const out: Array<{ item: Record; container: E }> = []; + for (const container of overlays) { + for (const item of this.expandRuntimeViewContainer(type, container.data, { packageId: container.packageId })) { + out.push({ item, container }); + } + } + return out; + } + + /** + * [#21442] The item the list read serves under `request.name` when that + * name is ROW-LESS — no stored row of its own — and a stored view + * container in this caller's scope expands it; `undefined` otherwise. + * + * The list read ({@link readFlattenedMetaItems}) expands every stored + * container it reads into its own answer, so `GET /meta/view?object=` + * lists the container's views. Nothing else stores or registers those + * views on every kernel: the registry hydration that does + * ({@link hydrateExpandedViewItems}) runs only on an unscoped kernel and + * only for an environment-wide row. So the by-name read answered an + * expanded name only there, and answered nothing on an + * environment-scoped kernel or for an organization-scoped container — and, + * where the name is one a package also ships, answered the packaged row + * instead of the one the list serves. + * + * The answer is the list read's own, by construction: the same rows + * ({@link readActiveOverlayRows}, same `packageId`, same gated `orgId`), + * the same parse ({@link storedOverlayEntries}) and the same expansion + * ({@link expandStoredViewContainers} over + * {@link expandRuntimeViewContainer}), the last expansion of the name + * winning as it does in the list. Nothing is persisted or registered: an + * expansion is derived from its container on every read, so there is no + * second copy to drift from it (ADR-0005 keys an overlay by its own name). + * ⛔ No kernel-specific branch — every kernel answers through this path. + * + * A stored row of this very name is the name's own row and is answered + * as such by the caller's own read, never an expansion. The `container` + * returned is the stored row the item derives from — its own name, body, + * package and organization — which the layered read reports as the + * name's provenance and the history and diff reads resolve to. + */ + private async resolveRowlessExpandedView( + request: { type: string; name: string; packageId?: string }, + orgId: string | undefined, + ): Promise { + if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'view') return undefined; + let records: any[] = []; + try { + records = await this.readActiveOverlayRows( + { type: request.type, ...(request.packageId ? { packageId: request.packageId } : {}) }, + orgId, + ); + } catch (error) { + // [#5532] The list read's rule: only an unprovisioned store means + // "no rows". Any other failure is not answered as "nothing expands + // this name". + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + if (records.some((record) => record?.name === request.name)) return undefined; + let found: RowlessExpandedView | undefined; + for (const expanded of this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, records))) { + if (expanded.item.name === request.name) found = expanded; + } + return found; + } + async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); @@ -9033,6 +9176,23 @@ export class ObjectStackProtocolImplementation implements 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, orgId); + 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 @@ -9541,6 +9701,28 @@ export class ObjectStackProtocolImplementation implements 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` the scope that row + // was read from. `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, orgId); + if (expandedFrom !== undefined) { + overlay = expandedFrom.container.data; + overlayScope = expandedFrom.container.organizationId === null ? 'env' : 'org'; + } + } + // [#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 @@ -9585,9 +9767,15 @@ export class ObjectStackProtocolImplementation implements // 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. - const effectiveBase: unknown | null = overlay !== null && !this.isShippedFlowName(request.type, request.name) - ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) - : code; + // + // [#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. + const effectiveBase: unknown | null = expandedFrom !== undefined + ? expandedFrom.item + : overlay !== null && !this.isShippedFlowName(request.type, request.name) + ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) + : code; const effective: unknown | null = this.governServedObject(request.type, effectiveBase); const _diagnostics = @@ -18961,6 +19149,20 @@ export class ObjectStackProtocolImplementation implements && !ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) { return { events: [] }; } + // [#21442] A view name with no stored row of its own that a stored + // view container in this caller's scope expands — a name the by-name + // read answers with the container's expansion — was never stored, so + // it has no change log of its own and none is synthesized for it. Its + // history is the container's own row's, read exactly as this method + // reads it under the container's own name, and the answer says so: + // every event's `ref.name` names the container. + const expandedFrom = await this.resolveRowlessExpandedView( + { type: singularType, name: request.name }, + organizationIdForMetaRead(singularType, request.organizationId), + ); + if (expandedFrom !== undefined) { + return this.historyMetaItem({ ...request, name: expandedFrom.container.name }); + } const orgId = request.organizationId ?? null; const repo = this.getOverlayRepo(orgId); const ref = { @@ -23276,6 +23478,21 @@ export class ObjectStackProtocolImplementation implements // the read below reads it as "the key the row is stored under" — the same // shape {@link rollbackMetaItem} keeps. const singularType = request.type; + // [#21442] A view name with no stored row of its own that a stored + // view container in this caller's scope expands — a name the by-name + // read answers with the container's expansion — has no versions of its + // own, and none are synthesized for it. The comparison is the + // container's own row's, made exactly as this method makes it under the + // container's own name, and the answer says so: its `name` is the + // container's — the item actually diffed, the same rule the echoed + // `type` follows above. + const expandedFrom = await this.resolveRowlessExpandedView( + { type: singularType, name: request.name }, + organizationIdForMetaRead(singularType, request.organizationId), + ); + if (expandedFrom !== undefined) { + return this.diffMetaItem({ ...request, name: expandedFrom.container.name }); + } const orgId = request.organizationId ?? null; // [#8798] Read the history rows DIRECTLY, once. `historyMetaItem` // cannot serve this function: its `MetadataEvent` shape doesn't carry From de471c819c4738cbddd72557976ab5c87eaaa6fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:24:46 +0000 Subject: [PATCH 2/5] test(metadata-protocol): pin the by-name, layers, history and diff answers for a name a stored view container expands Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index b3e3931aaa..3bd357d9c4 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -774,6 +774,172 @@ describe('#21334 a container on another package\'s object never takes that packa expect(served.filter((v) => v._packageId === REPAIR)).toEqual([]); await expectEveryPackagedNameIntact(protocol); }); + + /** + * #21442 — every name the object door lists answers the same row by name, + * on both kernels, for every member kind and every container scope. + * + * The list read expands each stored container it reads into its own answer; + * nothing else stores or registers those views on every kernel. Measured on + * `origin/main` before this change, with this harness: the by-name read + * answered an expanded name only on an unscoped kernel and only for an + * environment-wide container (registry hydration), and answered nothing on + * `env_local` or for an organization-scoped container. Triage's ruling A: + * the by-name read expands the in-scope containers through the function the + * list read uses, ⛔ no second expansion rule and ⛔ no kernel-specific + * branch. The container's own name stays its stored row — the control. + */ + describe('#21442 a name a stored container expands answers by name what the object door lists', () => { + const withoutDiagnostics = (item: any) => { + if (!item || typeof item !== 'object') return item; + const { _diagnostics: _drop, ...rest } = item; + return rest; + }; + const ownNames = (served: any[]) => served.filter((v) => String(v.name).startsWith(`${TASK}.${OWN}`)); + const layers = async (protocol: Protocol, name: string, organizationId?: string) => + (await protocol.getMetaItemLayered({ type: 'view', name, ...scoped(organizationId) })) as any; + const history = async (protocol: Protocol, name: string, organizationId?: string) => + (await protocol.historyMetaItem({ type: 'view', name, ...scoped(organizationId) })).events; + const diff = async (protocol: Protocol, name: string, organizationId?: string) => + (protocol as any).diffMetaItem({ type: 'view', name, ...scoped(organizationId) }); + /** What the registry holds for `view`, by key — reads must leave it as they found it. */ + const registrySnapshot = (registry: ReturnType) => + JSON.stringify(registry.listItems('view').map((it) => [it.name, it._packageId ?? null]).sort()); + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const c of CONTAINERS) { + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + it(`${c.arm}, member ${kind}: every name the object door lists answers that same item by name`, async () => { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + + const served = await objectDoor(protocol, c.organizationId); + expect(ownNames(served).map((v) => v.name), 'the expanded name is listed').toEqual([m.servedAs]); + for (const listed of served) { + const read = await byNameDoor(protocol, listed.name, c.organizationId); + expect(withoutDiagnostics(read), `${listed.name} by name`).toEqual(withoutDiagnostics(listed)); + } + // CONTROL — the container's own name is still its stored row. + const row = await byNameDoor(protocol, OWN, c.organizationId); + expect(row?.name).toBe(OWN); + expect(row?.object).toBe(TASK); + for (const key of Object.keys(m.member)) expect(row?.[key], `the stored container carries ${key}`).toEqual(m.member[key]); + }); + } + + it(`${c.arm}: the layers name the container and its scope; history and diff resolve to the container's own row`, async () => { + const { protocol } = showcaseHarness(environmentId); + const member = MEMBER_CASES['listViews.*']; + await save(protocol, OWN, { name: OWN, object: TASK, ...member.member }, c); + const expanded = member.servedAs; + + const layered = await layers(protocol, expanded, c.organizationId); + // `overlay` is the container's own stored row, as the + // layers read reports a stored row; `overlayScope` the + // scope it was read from. + expect(layered.overlay?.name).toBe(OWN); + expect(layered.overlay?.listViews).toEqual(member.member.listViews); + expect(layered.overlay?._packageId).toBe(c.ownPackage); + expect(layered.overlayScope).toBe(c.organizationId ? 'org' : 'env'); + // `effective` is what the by-name read answers. + expect(withoutDiagnostics(layered.effective)) + .toEqual(withoutDiagnostics(await byNameDoor(protocol, expanded, c.organizationId))); + // CONTROL — the container's own name reports its own row, unchanged. + const ownLayers = await layers(protocol, OWN, c.organizationId); + expect(ownLayers.overlay?.name).toBe(OWN); + expect(ownLayers.effective?.listViews).toEqual(member.member.listViews); + + const ownHistory = await history(protocol, OWN, c.organizationId); + expect(ownHistory.length, 'the container has a change log of its own').toBeGreaterThan(0); + const expandedHistory = await history(protocol, expanded, c.organizationId); + expect(expandedHistory, 'the container\'s own log, nothing synthesized').toEqual(ownHistory); + expect(expandedHistory.every((e: any) => e.ref.name === OWN), 'every event names the container').toBe(true); + + const ownDiff = await diff(protocol, OWN, c.organizationId); + const expandedDiff = await diff(protocol, expanded, c.organizationId); + expect(expandedDiff).toEqual(ownDiff); + expect(expandedDiff.name, 'the answer names the item actually diffed').toBe(OWN); + }); + + it(`${c.arm}: the reads persist and register nothing, and a name nothing expands still answers nothing`, async () => { + const { protocol, rows, registry } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }, c); + const rowsBefore = JSON.stringify([...rows.keys()].sort()); + const registryBefore = registrySnapshot(registry); + + const expanded = MEMBER_CASES['listViews.*'].servedAs; + await byNameDoor(protocol, expanded, c.organizationId); + await layers(protocol, expanded, c.organizationId); + await history(protocol, expanded, c.organizationId); + await diff(protocol, expanded, c.organizationId); + expect(JSON.stringify([...rows.keys()].sort()), 'no derived row is stored').toBe(rowsBefore); + expect(registrySnapshot(registry), 'no derived item is registered by a read').toBe(registryBefore); + + const nothing = `${TASK}.${OWN}.not_a_member`; + expect(await byNameDoor(protocol, nothing, c.organizationId)).toBeUndefined(); + expect(await history(protocol, nothing, c.organizationId), 'no history for a name never stored').toEqual([]); + const layeredNothing = await layers(protocol, nothing, c.organizationId); + expect([layeredNothing.overlay, layeredNothing.overlayScope, layeredNothing.effective]).toEqual([null, null, null]); + }); + } + + it('ISOLATION — an organization-scoped container\'s names answer nothing by name for another organization', async () => { + const { protocol } = showcaseHarness(environmentId); + const org = CONTAINERS.find((c) => c.organizationId !== undefined)!; + await save(protocol, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }, org); + const expanded = MEMBER_CASES['listViews.*'].servedAs; + + expect(await byNameDoor(protocol, expanded, ORG)).toBeTruthy(); + expect(ownNames(await objectDoor(protocol, 'org_globex'))).toEqual([]); + expect(await byNameDoor(protocol, expanded, 'org_globex')).toBeUndefined(); + expect(await byNameDoor(protocol, expanded)).toBeUndefined(); + }); + + for (const organizationId of [undefined, ORG]) { + it(`a tenant overlay of the package's own container (${organizationId ? 'organization-scoped' : 'environment-wide'}): each name it expands answers the overlay's view by name, not the packaged one`, async () => { + const { protocol } = showcaseHarness(environmentId); + const overlay = { + name: TASK, + list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] }, + listViews: { in_progress: { label: 'Customized In Progress', type: 'grid', data, columns: [{ field: 'title' }] } }, + }; + await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay, ...scoped(organizationId) } as any); + + const served = await objectDoor(protocol, organizationId); + for (const name of [DEFAULT, `${TASK}.in_progress`]) { + const listed = named(served, name); + expect(listed, `${name} is listed once`).toHaveLength(1); + expect(String(listed[0].label)).toMatch(/^Customized/); + const read = await byNameDoor(protocol, name, organizationId); + expect(withoutDiagnostics(read), `${name} by name`).toEqual(withoutDiagnostics(listed[0])); + } + // The layers: the packaged item, the overlay that customizes it, and the result. + const layered = await layers(protocol, DEFAULT, organizationId); + expect(layered.code?.label).toBe('All Tasks'); + expect(layered.overlay?.name).toBe(TASK); + expect(layered.overlayScope).toBe(organizationId ? 'org' : 'env'); + expect(layered.effective?.label).toBe('Customized'); + }); + } + }); + } + + it('both kernels answer every listed name with the same item', async () => { + for (const c of CONTAINERS) { + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + const answers = []; + for (const [, environmentId] of KERNELS) { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + answers.push(withoutDiagnostics(await byNameDoor(protocol, m.servedAs, c.organizationId))); + } + expect(answers[0], `${c.arm}, member ${kind}`).toBeTruthy(); + expect(answers[1], `${c.arm}, member ${kind}`).toEqual(answers[0]); + } + } + }); + }); }); /** From f475de2385a1eaac0911890874d1704fec85ef43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:31:10 +0000 Subject: [PATCH 3/5] test(metadata-protocol): the read-gate doubles answer the container selection a row-less view name now reads Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- ...et-meta-item-layered-org-read-gate.test.ts | 58 ++++++++++++------- .../src/get-meta-item-org-read-gate.test.ts | 58 ++++++++++++------- 2 files changed, 74 insertions(+), 42 deletions(-) diff --git a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts index f95253c6f4..ac22c32626 100644 --- a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts +++ b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts @@ -97,33 +97,46 @@ const storedRow = ( * The engine double: `findOne` over a row table, plus the registry surface the * layered read touches on its way past the overlay. * - * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path - * under test issues exactly one verb, and a double declaring verbs no case - * exercises would owe `check:engine-double-contract` a dispatch contract that - * protects nothing. Same shape the two sibling read-gate pins drive. + * `find` is the second verb the read path issues, and only for a `view` name + * with no stored row of its own: the read then selects the stored view + * containers that might expand that name, through the list read's own row + * selection (#21442). It records into `finds` — the same partitions question + * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a + * double declaring verbs no case exercises would owe + * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the two sibling read-gate pins drive. */ function makeHarness(rows: StoredRow[]) { const findOnes: Array> = []; + const finds: Array> = []; + const matching = (where: Record) => { + // `check:where-matcher` — a hand-written matcher with no combinator + // branch reads `$and` as a field name and answers the wrong question + // rather than failing. Refuse the shape this double does not + // implement, matching the sibling doubles' convention. + for (const k of Object.keys(where)) { + if (k.startsWith('$')) { + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + return (r as unknown as Record)[k] === v; + }), + ); + }; const engine: any = { + async find(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + const where = opts?.where ?? {}; + finds.push({ ...where }); + return matching(where); + }, async findOne(table: string, opts?: { where?: Record }) { if (table !== 'sys_metadata') return undefined; const where = opts?.where ?? {}; findOnes.push({ ...where }); - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.find((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); + return matching(where)[0]; }, registry: { registerItem: () => undefined, @@ -138,7 +151,7 @@ function makeHarness(rows: StoredRow[]) { }, }; const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes }; + return { protocol, findOnes, finds }; } /** Every `organization_id` partition the engine was asked for, deduplicated. */ @@ -443,11 +456,14 @@ describe('§5 an already-gating caller receives the same scope it did before', ( // for `view` gets the org partition read, exactly as before. const gated = organizationIdForMetaRead('view', ORG); expect(gated).toBe(ORG); - const { protocol, findOnes } = makeHarness([]); + const { protocol, findOnes, finds } = makeHarness([]); await protocol.getMetaItemLayered({ type: 'view', name: 'probe', organizationId: gated, }); expect(partitions(findOnes)).toEqual([null, ORG]); + // [#21442] `probe` has no row of its own, so the read also selects the + // stored containers that might expand it — from the same partitions. + expect(partitions(finds)).toEqual([null, ORG]); }); }); diff --git a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts index 0893d2d2a4..50ae63a19b 100644 --- a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts +++ b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts @@ -109,33 +109,46 @@ const storedRow = ( * The engine double: `findOne` over a row table, plus the registry surface the * single-item read path touches on its way past the overlay. * - * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path - * under test issues exactly one verb, and a double declaring verbs no case - * exercises would owe `check:engine-double-contract` a dispatch contract that - * protects nothing. Same shape the sibling plural-door pin drives. + * `find` is the second verb the read path issues, and only for a `view` name + * with no stored row of its own: the read then selects the stored view + * containers that might expand that name, through the list read's own row + * selection (#21442). It records into `finds` — the same partitions question + * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a + * double declaring verbs no case exercises would owe + * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the sibling plural-door pin drives. */ function makeHarness(rows: StoredRow[]) { const findOnes: Array> = []; + const finds: Array> = []; + const matching = (where: Record) => { + // `check:where-matcher` — a hand-written matcher with no combinator + // branch reads `$and` as a field name and answers the wrong question + // rather than failing. Refuse the shape this double does not + // implement, matching the sibling doubles' convention. + for (const k of Object.keys(where)) { + if (k.startsWith('$')) { + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + return (r as unknown as Record)[k] === v; + }), + ); + }; const engine: any = { + async find(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + const where = opts?.where ?? {}; + finds.push({ ...where }); + return matching(where); + }, async findOne(table: string, opts?: { where?: Record }) { if (table !== 'sys_metadata') return undefined; const where = opts?.where ?? {}; findOnes.push({ ...where }); - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.find((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); + return matching(where)[0]; }, registry: { registerItem: () => undefined, @@ -150,7 +163,7 @@ function makeHarness(rows: StoredRow[]) { }, }; const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes }; + return { protocol, findOnes, finds }; } /** Every `organization_id` partition the engine was asked for, deduplicated. */ @@ -389,9 +402,12 @@ describe('§4 an already-gating caller receives the same scope it did before', ( // the org partition read, exactly as before this change. const gated = organizationIdForMetaRead('view', ORG); expect(gated).toBe(ORG); - const { protocol, findOnes } = makeHarness([]); + const { protocol, findOnes, finds } = makeHarness([]); await protocol.getMetaItem({ type: 'view', name: 'probe', organizationId: gated }); expect(partitions(findOnes)).toEqual([null, ORG]); + // [#21442] `probe` has no row of its own, so the read also selects the + // stored containers that might expand it — from the same partitions. + expect(partitions(finds)).toEqual([null, ORG]); }); }); From d020acb4b22482518c1e51bb83b50c27d6966b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:46:10 +0000 Subject: [PATCH 4/5] chore(changeset): metadata-protocol patch for the by-name read of an expanded view name Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .changeset/21442-by-name-expanded-view.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/21442-by-name-expanded-view.md diff --git a/.changeset/21442-by-name-expanded-view.md b/.changeset/21442-by-name-expanded-view.md new file mode 100644 index 0000000000..0683397020 --- /dev/null +++ b/.changeset/21442-by-name-expanded-view.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a view a stored view container expands answers by name what the object door lists, on every kernel and for every container scope + +Clause-②: no + +- **What changed.** `GET /api/v1/meta/view?object=…` lists the views a stored view container expands, and the by-name read now answers each of those names with the same item. Before, `getMetaItem` expanded no container: it answered such a name only on an unscoped kernel and only for an environment-wide container, where the registry held a hydrated copy. On an environment-scoped kernel, and for an organization-scoped container on any kernel, it answered nothing. Where the name is one a package also ships, such as `.default` under a tenant's overlay of that package's container, it answered the packaged view while the list served the overlay's. +- **How.** The by-name read selects the stored containers in the caller's scope with the list read's own row selection, and expands them with the list read's own expansion. Nothing is persisted or registered, and a stored row of the name itself still answers first. +- **Layers, history and diff for such a name.** `getMetaItemLayered` reports the container's own stored row as `overlay`, with the scope it was read from as `overlayScope`, and the expanded view as `effective`. `historyMetaItem` and `diffMetaItem` answer exactly what they answer under the container's own name, and say so: every event's `ref.name` and the diff's `name` are the container's. No history is made up for a name that was never stored. +- **What does not change.** The container's own name still answers its stored row. The save door is unchanged, including a write by an expanded name. No response shape gains or loses a key. From 3558ad6e70296c6d74fda97f4a9862574d4aad7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:00:03 +0000 Subject: [PATCH 5/5] test(metadata-protocol): extend the read-gate doubles with find rather than declaring a new engine double Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/get-meta-item-layered-org-read-gate.test.ts | 12 ++++++------ .../src/get-meta-item-org-read-gate.test.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts index ac22c32626..22caf11e94 100644 --- a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts +++ b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts @@ -126,12 +126,6 @@ function makeHarness(rows: StoredRow[]) { ); }; const engine: any = { - async find(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }, async findOne(table: string, opts?: { where?: Record }) { if (table !== 'sys_metadata') return undefined; const where = opts?.where ?? {}; @@ -150,6 +144,12 @@ function makeHarness(rows: StoredRow[]) { applyNavContributions: (app: unknown) => app, }, }; + engine.find = async (table: string, opts?: { where?: Record }) => { + if (table !== 'sys_metadata') return []; + const where = opts?.where ?? {}; + finds.push({ ...where }); + return matching(where); + }; const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; return { protocol, findOnes, finds }; } diff --git a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts index 50ae63a19b..36b0647d9c 100644 --- a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts +++ b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts @@ -138,12 +138,6 @@ function makeHarness(rows: StoredRow[]) { ); }; const engine: any = { - async find(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }, async findOne(table: string, opts?: { where?: Record }) { if (table !== 'sys_metadata') return undefined; const where = opts?.where ?? {}; @@ -162,6 +156,12 @@ function makeHarness(rows: StoredRow[]) { applyNavContributions: (app: unknown) => app, }, }; + engine.find = async (table: string, opts?: { where?: Record }) => { + if (table !== 'sys_metadata') return []; + const where = opts?.where ?? {}; + finds.push({ ...where }); + return matching(where); + }; const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; return { protocol, findOnes, finds }; }