From 78a34aaddfca7e513ccf02d8890b46a850b41c34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 22:59:01 +0000 Subject: [PATCH 1/7] fix(metadata-protocol): one collision predicate for a stored view container at the save door; a package-less container row named after a shipped view item belongs to no package (WIP) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 393 +++++++++++---------- 1 file changed, 212 insertions(+), 181 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 56bc12dce76..e5765e5ba0f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -107,10 +107,6 @@ import { isMissingTableError } from '@objectstack/metadata/errors'; // door (`saveMetaItem`), the restore doors (`rollbackMetaItem`, `revertCommit`) // and the draft promotion (`promoteDraftForPublish`). import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; -// [#21620] The one spelling of "which object a view container binds to" — the -// derivation the source registrars file a container under — so the save door's -// sibling-expansion refusal judges "the same object" as every other door does. -import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; import type { BatchUpdateRequest, BatchUpdateResponse, @@ -17054,10 +17050,22 @@ export class ObjectStackProtocolImplementation implements /** * [#21334] The package a runtime view container row belongs to: the * package its row is bound to, else — for a package-less row that is the - * name-keyed overlay of a packaged item (ADR-0005), such as a tenant's - * overlay of a package's `` container — the package of the - * artifact it overlays, which is the slot the package-aware merge seats - * it in. `undefined` for a package-less row that overlays nothing. + * name-keyed overlay of a packaged container (ADR-0005), such as a + * tenant's overlay of a package's `` container — the package of + * the container it overlays, which is the slot the package-aware merge + * seats it in. `undefined` for a package-less row that overlays nothing. + * + * [#21638] Only a shipped CONTAINER is something a container row + * overlays. A container row stored under the name of a view ITEM a + * package ships is not that item's overlay — a container is not a view — + * so it belongs to no package, and its expansion is placed as any + * package-less container's is. Read through the shipped item instead, + * such a row was judged a container of the shipping package: on that + * package's object its bare `list` took `.default` and replaced + * the packaged default on both doors, wearing the package's `_packageId`, + * the override #21334's arm exists to rule out. The save door refuses the + * shape now ({@link viewContainerNameCollisionRefusal}); a row stored + * before that is read this way, with no re-save. */ private runtimeViewContainerPackage( type: string, @@ -17068,9 +17076,10 @@ export class ObjectStackProtocolImplementation implements if (typeof bound === 'string' && bound !== '' && bound !== 'sys_metadata') return bound; } if (typeof container.name !== 'string' || container.name === '') return undefined; - const overlaid = (this.lookupArtifactItem(type, container.name) as { _packageId?: unknown } | undefined) - ?._packageId; - return typeof overlaid === 'string' && overlaid !== '' ? overlaid : undefined; + const overlaid = this.lookupArtifactItem(type, container.name) as { _packageId?: unknown } | undefined; + if (!isAggregatedViewContainer(overlaid)) return undefined; + const overlaidPackage = overlaid?._packageId; + return typeof overlaidPackage === 'string' && overlaidPackage !== '' ? overlaidPackage : undefined; } /** @@ -17889,148 +17898,82 @@ export class ObjectStackProtocolImplementation implements } /** - * [#21558] The save door's refusal of a view container saved under a name - * its OWN expansion produces — `{ name: 'crm_lead.default', object: - * 'crm_lead', list }` saved as `crm_lead.default`, whose bare `list` - * expands to exactly that name. - * - * Both read doors give a name with a stored row of its own that row, and - * let an expansion fill only a name with no row (#21510's one predicate, - * `namesWithOwnStoredRow`). Such a container IS the row of that name, so - * its own expansion never fills it: the object door, which never - * enumerates a container, lists nothing under the name, and the by-name - * read answers the raw container. No door answers a view item for it, and - * nothing told the author why. Triage's ruling refuses the shape here, at - * authoring (Prime Directive 12), and keeps the readers' one predicate - * whole: ⛔ no second own-row test in the readers. - * - * "A name its own expansion produces" is answered by the readers' own - * expansion, {@link expandRuntimeViewContainer}, never by a copy of its - * naming, so the save door and the read doors cannot disagree about it: - * every member kind and the expander's de-duplication are covered as the - * readers place them. A container on another package's object expands - * under its own name (#21334), as `.…`, which is - * never the container name itself, so that arm is never refused. The - * package binding is the request's, as the registry write-through - * registers the expansion. - * - * The body judged is the one the author sent, with the door's own `name` - * stamp ({@link normalizeViewMetadata}: a missing or falsy `name` becomes - * the save name) applied first, since the expansion of an unnamed - * container is placed by that name. It is asked BEFORE that function's - * identity patch: a container whose only member is `form` is not one of - * the shapes the patch leaves alone, so under the name of a registered - * view item it would take that item's `viewKind`, stop being a container, - * and reach the schema as a malformed view item instead of this refusal. - * - * A view item (`viewKind` set) is not a container, so a view item saved - * under an expanded name is untouched: it is the sanctioned override for - * that name. Rows already stored in this shape are untouched too: the - * read doors serve them as before, and only a new save is refused. + * [#21639] The save door's ONE collision predicate for a view container: + * a container is refused when its save name, or any name its expansion + * produces, is a name already served from elsewhere — the family's rule, + * which replaces its two one-shape checks (#21558's own expansion, + * #21620's sibling of the same object) and adds the shapes they left open. + * + * Both read doors give a name with a stored row of its own that row + * (#21510's one predicate, {@link namesWithOwnStoredRow}), and fill a + * row-less name with an expansion, the last one read winning + * ({@link expandStoredViewContainers}). So a container whose ROW name is + * served from elsewhere hides that view on both doors and, being no view + * itself, leaves no read answering a view under the name; a container + * whose EXPANSION takes such a name replaces that view on both doors with + * no word to anyone (ADR-0126: no silent override). Neither is ever what + * the author meant, so both are refused here, at authoring (Prime + * Directive 12), and the readers keep their one predicate: ⛔ no second + * own-row test and no precedence rule in the readers. + * + * "Elsewhere" is triage's two sources, judged by the readers' own pieces, + * never a copy of them: + * - another stored container's expansion, in the caller's selection, + * whatever that container's object: the rows + * {@link readActiveOverlayRows} selects through the readers' gate + * ({@link organizationIdForMetaRead}) with no package filter, parsed by + * {@link storedOverlayEntries} and expanded by + * {@link expandStoredViewContainers} with each row's own package + * binding. The row stored under the save name is left out: it is the + * row this save replaces, so a container's own re-save is never its + * own sibling; + * - a view item a package ships ({@link lookupArtifactItem}, the + * registry's artifact read, which never answers a tenant-authored + * row), except the views of the shipped container this row overlays + * by its own name ({@link overlaidShippedContainerViewNames}): ADR-0005 + * keys an overlay by its own name, so an overlay of a package's + * container stands in for that container and its views. + * A name the container's OWN expansion produces is a third source for its + * save name only: as the row of that name it would hide its own view. + * + * Every name the container would serve is its expansion as the readers + * place it, {@link expandRuntimeViewContainer} with the request's package + * binding (the binding the row is stored under), so every member kind, + * the expander's de-duplication and #21334's own-name arm on another + * package's object are judged where the readers put them. The body judged + * is the one the author sent, with the door's own `name` stamp applied + * first (a body with no `name` is judged under the save name), BEFORE + * {@link normalizeViewMetadata}'s identity patch: a `form`-only container + * under a registered view item's name would otherwise take that item's + * `viewKind` and reach the schema as a malformed view item. + * + * What still saves: a view item (`viewKind` set), which is not a + * container and under any of these names is that name's sanctioned + * override; a container under its object's name, or under any name of + * its own, whose expansion collides with nothing; an overlay of a + * package's own container; #21334's own-name arm; a container whose would-be + * sibling is in another organization (the caller's selection decides, as + * it does for the readers). Rows already stored in a refused shape keep + * their bytes and are served as before; a new save of one, a re-save + * included, is refused until its body stops colliding, and the re-savers + * that write through this door (`migrateStoredMetadata`, + * `duplicatePackage`) record that refusal as the row's failure. * * `VALIDATION_ERROR` / 400, the envelope of the name check it sits beside - * (`savedItemNameRefusal`): an authoring refusal of the request's own - * name, decided from the body. The prescription is the ruling's: save the - * container under its object's name, or save a view item under the - * expanded name. Runtime words carry no tracker number. + * (`savedItemNameRefusal`). The message names the other owner (the stored + * container, the shipping package, or the container's own expansion) and + * gives the family's prescription: add the view as a member of the + * container that owns the name, or save a view item under the name. ⛔ It + * never prescribes a save under a name another stored row holds: an + * author (or an AI) following such an arm literally would replace that + * row and drop the very views this refusal keeps serving. Runtime words + * carry no tracker number. */ - private containerOwnExpansionNameRefusal( + private async viewContainerNameCollisionRefusal( type: string, item: unknown, saveName: string, packageId: string | null | undefined, - ): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined { - if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; - const body = item as Record; - const stamped = body.name ? body : { ...body, name: saveName }; - const own = this.expandRuntimeViewContainer(type, stamped, { packageId }) - .find((expanded) => expanded.name === saveName); - if (!own) return undefined; - const object = String(own.object); - const err = new Error( - `Invalid view container: it is saved under '${saveName}', which is a name its own expansion ` - + `produces (its ${String(own.viewKind)} view on '${object}'). An expanded view fills only a name ` - + `that has no stored row of its own, and this container would be that row, so no read would answer ` - + `a view under '${saveName}'. Save the container under its object's name, '${object}', or save a ` - + `view item (name, object, viewKind and config) under '${saveName}'.`, - ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - return err; - } - - /** - * [#21620] The save door's refusal of a view container saved under a name - * that ANOTHER stored container of the same object expands to — with - * `{ name: 'crm_lead', object: 'crm_lead', listViews: { pipeline } }` - * stored, a second container `{ object: 'crm_lead', list }` saved as - * `crm_lead.pipeline`. - * - * The harm is #21558's, reached through a sibling: the second container - * becomes the stored row of `crm_lead.pipeline`, and both read doors give - * a name with a row of its own that row (#21510's one predicate, - * {@link namesWithOwnStoredRow}). So the first container's expansion no - * longer fills the name, the object door — which never enumerates a - * container — lists nothing under it, and the by-name read answers the raw - * second container: the sibling's view is gone from both doors and no door - * answers a view item for the name. #21558's check cannot see this: the - * second container's OWN expansion is `crm_lead.default`, never its save - * name. - * - * Triage's ruling on the card named a broader check — a container's name - * must be its object's name — and made it conditional on a census, with - * THIS narrower check as the fallback. The census hit: this door keeps a - * container saved under a name other than its object (#13407's live - * authoring path, which the platform checklist's live view-authoring item - * drives; #21412's P2 and P2b, ruled; #21334's arm expands one under its - * own name, ruled), and Studio's metadata editor re-saves such a container - * under its stored name. So the name is judged only against what the - * other stored containers of the same object expand to. - * - * The judgment is the readers' own, never a copy of it: - * - the rows are the ones {@link readActiveOverlayRows} selects for this - * caller, through the read gate the readers apply - * ({@link organizationIdForMetaRead}) and with no package filter, so - * every reader whose selection holds this container and a sibling is - * covered for this caller's scope; - * - each row is parsed by {@link storedOverlayEntries} and expanded by - * {@link expandStoredViewContainers}, with the row's own package - * binding, so every member kind, the expander's de-duplication and - * #21334's arm are judged where the readers place them; - * - the row stored under the save name itself is left out: it is the row - * this save replaces, not a sibling; - * - "the same object" is the expanded view's `object` against - * {@link deriveViewContainerObject} of the body, the one derivation - * every door files a container under. - * - * The body judged is the one the author sent, with the door's own `name` - * stamp applied first (a body with no `name` is judged under the save - * name), BEFORE {@link normalizeViewMetadata}'s identity patch — on an - * unscoped kernel the sibling's expansion is registered under the name, - * and a `form`-only container would take its `viewKind` there and reach - * the schema as a malformed view item instead of this refusal. - * - * A view item (`viewKind` set) is not a container and is untouched: under - * an expanded name it is that name's sanctioned override. Rows already - * stored in this shape keep their bytes and are served as before; only a - * new save of one is refused, and the re-savers that write through this - * door (`migrateStoredMetadata`, `duplicatePackage`) record that refusal - * as the row's failure instead of re-saving it. - * - * `VALIDATION_ERROR` / 400, the envelope of the two name checks it sits - * beside. The prescription names the stored container that expands the - * name, and gives two arms: add the view as a member of THAT container, or - * save a view item under the expanded name. ⛔ It never prescribes a save - * under a name another stored container holds — not even the object's own - * name, which in the card's pair IS the sibling: an author (or an AI) - * following such an arm literally would replace the sibling's row and drop - * the very view this refusal keeps serving. Runtime words carry no tracker - * number. - */ - private async containerSiblingExpansionNameRefusal( - type: string, - item: unknown, - saveName: string, organizationId: string | undefined, ): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return undefined; @@ -18038,8 +17981,8 @@ export class ObjectStackProtocolImplementation implements const body = item as Record; const stamped = body.name ? body : { ...body, name: saveName }; if (!isAggregatedViewContainer(stamped)) return undefined; - const object = deriveViewContainerObject(stamped); - if (!object) return undefined; + const served = this.expandRuntimeViewContainer(type, stamped, { packageId }); + let records: any[] = []; try { records = await this.readActiveOverlayRows({ type }, organizationIdForMetaRead(type, organizationId)); @@ -18048,22 +17991,116 @@ export class ObjectStackProtocolImplementation implements // rows". Any other failure is not answered as "no sibling". this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - const siblings = this.storedOverlayEntries({ type }, records) - .filter((entry) => entry.name !== saveName); - const hit = this.expandStoredViewContainers(type, siblings) - .find(({ item: expanded }) => expanded.name === saveName && expanded.object === object); - if (!hit) return undefined; - const err = new Error( - `Invalid view container: it is saved under '${saveName}', which is a name the stored container ` - + `'${hit.container.name}' expands (its ${String(hit.item.viewKind)} view on '${object}'). An expanded ` - + `view fills only a name that has no stored row of its own, and this container would be that row, so ` - + `that view would no longer be served and no read would answer a view under '${saveName}'. Add the ` - + `view as a member of the container '${hit.container.name}' (its list, listViews, form or formViews), ` - + `or save a view item (name, object, viewKind and config) under '${saveName}'.`, - ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - return err; + const siblings = this.expandStoredViewContainers( + type, + this.storedOverlayEntries({ type }, records).filter((entry) => entry.name !== saveName), + ); + const siblingServing = (name: string) => siblings.find(({ item: expanded }) => expanded.name === name); + const overlaid = this.overlaidShippedContainerViewNames(type, stamped, packageId); + const shippedServing = (name: string): Record | undefined => { + if (overlaid.has(name)) return undefined; + const artifact = this.lookupArtifactItem(type, name) as Record | undefined; + if (!artifact || isAggregatedViewContainer(artifact)) return undefined; + return typeof artifact._packageId === 'string' && artifact._packageId !== '' ? artifact : undefined; + }; + const refusal = (text: string) => { + const err = new Error(`Invalid view container: ${text}`) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + return err; + }; + const kindOn = (view: Record) => `${String(view.viewKind)} view on '${String(view.object)}'`; + const viewItem = (name: string) => `a view item (name, object, viewKind and config) under '${name}'`; + const asMemberOf = (container: string) => + `Add the view as a member of the container '${container}' (its list, listViews, form or formViews)`; + const ofItsOwn = 'the container under a name of its own that no package ships and no stored container expands'; + + // The save name: the row this container would be. + const rowHides = 'this container would be that row, so'; + const sibling = siblingServing(saveName); + if (sibling) { + return refusal( + `it is saved under '${saveName}', which is a name the stored container '${sibling.container.name}' ` + + `expands (its ${kindOn(sibling.item)}). An expanded view fills only a name that has no stored row of ` + + `its own, and ${rowHides} that view would no longer be served and no read would answer a view under ` + + `'${saveName}'. ${asMemberOf(sibling.container.name)}, or save ${viewItem(saveName)}.`, + ); + } + const shipped = shippedServing(saveName); + if (shipped) { + return refusal( + `it is saved under '${saveName}', which is the name of the ${kindOn(shipped)} the package ` + + `'${String(shipped._packageId)}' ships. A stored row under a packaged view's name takes that view's ` + + `place, and ${rowHides} the packaged view would no longer be served and no read would answer a view ` + + `under '${saveName}'. Save ${viewItem(saveName)} to override the packaged view, or save ` + + `${ofItsOwn}.`, + ); + } + const own = served.find((expanded) => expanded.name === saveName); + if (own) { + const object = String(own.object); + const objectRow = records.find((record) => record?.name === object && record?.name !== saveName); + const firstArm = objectRow === undefined + ? `Save the container under its object's name, '${object}'` + : isAggregatedViewContainer(this.storedOverlayEntries({ type }, [objectRow])[0]?.data) + ? asMemberOf(object) + : `Save ${ofItsOwn}`; + return refusal( + `it is saved under '${saveName}', which is a name its own expansion produces (its ${kindOn(own)}). An ` + + `expanded view fills only a name that has no stored row of its own, and ${rowHides} no read would ` + + `answer a view under '${saveName}'. ${firstArm}, or save ${viewItem(saveName)}.`, + ); + } + + // Every name its expansion produces: the views this container would serve. + for (const view of served) { + const name = String(view.name); + const taken = `it is saved under '${saveName}', and its ${kindOn(view)} would be served as '${name}'`; + const other = siblingServing(name); + if (other) { + return refusal( + `${taken}, which is a name the stored container '${other.container.name}' already expands (its ` + + `${kindOn(other.item)}). Two containers cannot serve one name: the one read last would replace ` + + `the other's view on both doors, and nothing would say why. ${asMemberOf(other.container.name)}, ` + + `or save ${viewItem(name)}.`, + ); + } + const packaged = shippedServing(name); + if (packaged) { + return refusal( + `${taken}, which is the name of the ${kindOn(packaged)} the package ` + + `'${String(packaged._packageId)}' ships. A container's view under a packaged view's name ` + + `replaces that view on both doors, and nothing would say why. Save ${viewItem(name)} to override ` + + `the packaged view, or give the member a key of its own that no package ships and no stored ` + + `container expands.`, + ); + } + } + return undefined; + } + + /** + * [#21639] The view names of the shipped container a container row + * overlays by its own name — the one source of "a view item a package + * ships" that row may replace. ADR-0005 keys an overlay by its own name, + * and the package-aware merge seats a row in its package's slot, so the + * overlaid container is the artifact of the row's own name in the row's + * own package ({@link runtimeViewContainerPackage}); a package-bound row + * overlays only its own package's artifact. Empty for a row that overlays + * no shipped container. + */ + private overlaidShippedContainerViewNames( + type: string, + container: Record, + packageId: string | null | undefined, + ): ReadonlySet { + const ownPackageId = this.runtimeViewContainerPackage(type, container, { packageId }); + if (ownPackageId === undefined || typeof container.name !== 'string') return new Set(); + const shipped = this.lookupArtifactItem(type, container.name, ownPackageId) as Record | undefined; + if (!isAggregatedViewContainer(shipped) || shipped?._packageId !== ownPackageId) return new Set(); + return new Set( + this.expandRuntimeViewContainer(type, shipped, { packageId: ownPackageId }).map((view) => String(view.name)), + ); } // [#21207] `parentVersion` is a CALLER's version token — the keyed form a @@ -18547,25 +18584,19 @@ export class ObjectStackProtocolImplementation implements const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save'); if (nameRefusal) throw nameRefusal; } - // [#21558] …and a view container saved under a name its OWN - // expansion produces, with the same envelope. Asked of the body as - // authored, before the stamp below can take a registry entry's - // `viewKind` onto it. See {@link containerOwnExpansionNameRefusal}. - { - const ownExpansionRefusal = this.containerOwnExpansionNameRefusal( - singularType, request.item, request.name, request.packageId, - ); - if (ownExpansionRefusal) throw ownExpansionRefusal; - } - // [#21620] …and a view container saved under a name ANOTHER stored - // container of the same object expands to, with the same envelope, - // judged by the readers' own row selection and expansion. Also - // before the stamp. See {@link containerSiblingExpansionNameRefusal}. + // [#21639] …and a view container whose save name, or any name its + // expansion produces, is a name already served from elsewhere — + // its own expansion, another stored container's expansion in the + // caller's selection, or a view item a package ships — with the + // same envelope. One predicate for the family, judged by the + // readers' own row selection and expansion, and asked of the body + // as authored, before the stamp below can take a registry entry's + // `viewKind` onto it. See {@link viewContainerNameCollisionRefusal}. { - const siblingExpansionRefusal = await this.containerSiblingExpansionNameRefusal( - singularType, request.item, request.name, request.organizationId, + const containerCollision = await this.viewContainerNameCollisionRefusal( + singularType, request.item, request.name, request.packageId, request.organizationId, ); - if (siblingExpansionRefusal) throw siblingExpansionRefusal; + if (containerCollision) throw containerCollision; } let baseline: unknown; if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view' From 0d95bb7cb432d37e85cb069d039d0b8bb104bb09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:05:43 +0000 Subject: [PATCH 2/7] test(metadata-protocol): the enumeration pin for the save door's one container collision predicate, and the package-less container row's attribution Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 463 ++++++++++++++++++ 1 file changed, 463 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 6b3924abe69..92b8c94848c 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1516,6 +1516,469 @@ describe('#21334 a container on another package\'s object never takes that packa }); } }); + + /** + * #21639, with #21638 folded in — the family's ONE collision predicate at + * the save door, and its enumeration pin (triage's acceptance). + * + * The rule: a stored view container is refused at save when its save + * name, or any name its expansion produces, is a name already served from + * elsewhere — another stored container's expansion in the caller's + * selection, whatever that container's object, or a view item a package + * ships. It replaces #21558's and #21620's one-shape checks; their blocks + * above keep their envelopes and their intent. + * + * Measured on `origin/main` before this change (`7b07749f05`), with this + * harness: every REFUSED row below that is not #21558's or #21620's own + * shape was accepted, and the doors then served the collision — the + * sibling's or the package's view gone, or replaced by the later + * container's. + * + * The table is the acceptance: one row per container shape, each either + * REFUSED (the ADR-0112 envelope, and the other owner and the colliding + * name named) or ALLOWED (with its reason). A row that is neither fails; + * the ruling's shapes and every cell of the predicate (which name × which + * owner) must each have a row, so a shape added without a verdict, or a + * cell left without a refused row, turns the pin red. + */ + describe('#21639 the save door\'s one collision predicate — the enumeration pin', () => { + const LEAD = 'crm_lead'; + const OTHER_ORG = 'org_globex'; + const leadData = { provider: 'object', object: LEAD }; + const leadList = (label: string) => ({ label, type: 'grid', data: leadData, columns: [{ field: 'name' }] }); + const taskList = (label: string) => ({ label, type: 'grid', data, columns: [{ field: 'title' }] }); + /** The stored container most rows sit beside: `crm_lead` with a default list and a `pipeline`. */ + const storedLead = { name: LEAD, object: LEAD, list: leadList('All Leads'), listViews: { pipeline: leadList('Lead Pipeline') } }; + const KANBAN = `${TASK}.kanban`; + /** A view item the showcase ships on its own, outside its `showcase_task` container. */ + const shippedKanban = { + name: KANBAN, object: TASK, viewKind: 'list', label: 'Board', + config: { type: 'kanban', data, columns: [{ field: 'title' }] }, + }; + function collisionHarness(environmentId?: string) { + const harness = showcaseHarness(environmentId); + harness.registry.registerItem('view', { ...shippedKanban }, 'name', SHOWCASE); + return harness; + } + + type Subject = 'its save name' | 'a name its expansion produces'; + type OwnerKind = 'its own expansion' | 'another stored container' | 'a view item a package ships'; + type Scope = 'environment-wide' | 'organization-scoped'; + /** `organizationId`: absent — the caller's own scope; `null` — environment-wide; a string — that organization. */ + interface Write { name: string; item: unknown; packageId?: string; organizationId?: string | null } + interface Served { object: string; name: string; label: string } + interface Shape { + shape: string; + /** The ruling's own name for the shape, where it names one. */ + ruled?: string; + scopes?: readonly Scope[]; + given?: readonly Write[]; + save: Write; + refused?: { subject: Subject; owner: OwnerKind; ownerName: string; collides: string; stillServed?: Served }; + allowed?: { because: string; serves: readonly Served[] }; + } + + const SHAPES: readonly Shape[] = [ + // ── REFUSED ───────────────────────────────────────────────────── + { + shape: 'a container saved under a name its own expansion produces', + ruled: '#21558\'s own-expansion name', + save: { name: `${LEAD}.default`, item: { name: `${LEAD}.default`, object: LEAD, list: leadList('Self') } }, + refused: { subject: 'its save name', owner: 'its own expansion', ownerName: LEAD, collides: `${LEAD}.default` }, + }, + { + shape: 'a container saved under a name its own expansion produces, while a stored container holds its object\'s name', + given: [{ name: LEAD, item: { name: LEAD, object: LEAD, listViews: { hot: leadList('Hot Leads') } } }], + save: { name: `${LEAD}.default`, item: { name: `${LEAD}.default`, object: LEAD, list: leadList('Self') } }, + refused: { + subject: 'its save name', owner: 'its own expansion', ownerName: LEAD, collides: `${LEAD}.default`, + stillServed: { object: LEAD, name: `${LEAD}.hot`, label: 'Hot Leads' }, + }, + }, + { + shape: 'a container of the same object saved under a name a stored container expands', + ruled: '#21620\'s sibling expansion of the same object', + given: [{ name: LEAD, item: storedLead }], + save: { name: `${LEAD}.pipeline`, item: { object: LEAD, list: leadList('Other') } }, + refused: { + subject: 'its save name', owner: 'another stored container', ownerName: LEAD, collides: `${LEAD}.pipeline`, + stillServed: { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }, + }, + }, + { + shape: 'a container bound to ANOTHER object saved under a name a stored container expands', + ruled: '(1)\'s other-object container', + given: [{ name: LEAD, item: storedLead }], + save: { name: `${LEAD}.pipeline`, item: { object: 'crm_account', list: { label: 'Accounts', type: 'grid', columns: [{ field: 'name' }] } } }, + refused: { + subject: 'its save name', owner: 'another stored container', ownerName: LEAD, collides: `${LEAD}.pipeline`, + stillServed: { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }, + }, + }, + { + shape: 'an UNBOUND container saved under a name a stored container expands', + ruled: '(1)\'s unbound container', + given: [{ name: LEAD, item: storedLead }], + save: { name: `${LEAD}.pipeline`, item: { list: { label: 'Unbound', type: 'grid', columns: [{ field: 'name' }] } } }, + refused: { + subject: 'its save name', owner: 'another stored container', ownerName: LEAD, collides: `${LEAD}.pipeline`, + stillServed: { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }, + }, + }, + { + shape: 'a second container of one object, under a free name, whose bare list takes .default', + ruled: '(2)\'s second container default', + given: [{ name: LEAD, item: storedLead }], + save: { name: 'lead_other_views', item: { object: LEAD, list: leadList('Other') } }, + refused: { + subject: 'a name its expansion produces', owner: 'another stored container', ownerName: LEAD, collides: `${LEAD}.default`, + stillServed: { object: LEAD, name: `${LEAD}.default`, label: 'All Leads' }, + }, + }, + { + shape: 'a container under its object\'s name, saved after a free-named container of that object took .default', + given: [{ name: 'lead_other_views', item: { name: 'lead_other_views', object: LEAD, list: leadList('Other') } }], + save: { name: LEAD, item: storedLead }, + refused: { + subject: 'a name its expansion produces', owner: 'another stored container', ownerName: 'lead_other_views', collides: `${LEAD}.default`, + stillServed: { object: LEAD, name: `${LEAD}.default`, label: 'Other' }, + }, + }, + { + shape: 'a package-less container saved under the name of a view item a package ships', + ruled: '#21638\'s shipped item name', + save: { name: `${TASK}.in_progress`, item: { name: `${TASK}.in_progress`, object: TASK, list: listView } }, + refused: { + subject: 'its save name', owner: 'a view item a package ships', ownerName: SHOWCASE, collides: `${TASK}.in_progress`, + stillServed: { object: TASK, name: `${TASK}.in_progress`, label: 'In Progress' }, + }, + }, + { + shape: 'a container in a writable package saved under the name of a view item another package ships', + save: { name: DEFAULT, item: { name: DEFAULT, object: TASK, list: listView }, packageId: REPAIR }, + refused: { + subject: 'its save name', owner: 'a view item a package ships', ownerName: SHOWCASE, collides: DEFAULT, + stillServed: { object: TASK, name: DEFAULT, label: 'All Tasks' }, + }, + }, + { + shape: 'an overlay of the package\'s own container whose new member takes the name of a view item the package ships on its own', + save: { + name: TASK, + item: { name: TASK, list: taskList('Overlay'), listViews: { in_progress: taskList('Overlay In Progress'), kanban: taskList('Mine') } }, + }, + refused: { + subject: 'a name its expansion produces', owner: 'a view item a package ships', ownerName: SHOWCASE, collides: KANBAN, + stillServed: { object: TASK, name: KANBAN, label: 'Board' }, + }, + }, + // ── ALLOWED ───────────────────────────────────────────────────── + { + shape: 'a view item saved under a name a stored container expands', + ruled: 'a view item under an expanded name (allowed)', + given: [{ name: LEAD, item: storedLead }], + save: { + name: `${LEAD}.pipeline`, + item: { name: `${LEAD}.pipeline`, object: LEAD, viewKind: 'list', label: 'ByNameRow', config: { type: 'grid', data: leadData, columns: [{ field: 'name' }] } }, + }, + allowed: { + because: 'a view item is not a container: under an expanded name it is that name\'s sanctioned override (#21510)', + serves: [{ object: LEAD, name: `${LEAD}.pipeline`, label: 'ByNameRow' }, { object: LEAD, name: `${LEAD}.default`, label: 'All Leads' }], + }, + }, + { + shape: 'a view item saved under the name of a view item a package ships', + save: { + name: `${TASK}.in_progress`, + item: { name: `${TASK}.in_progress`, object: TASK, viewKind: 'list', label: 'Overridden', config: { type: 'grid', data, columns: [{ field: 'title' }] } }, + }, + allowed: { + because: 'a view item is not a container: under a packaged view\'s name it is that view\'s sanctioned override by name', + serves: [{ object: TASK, name: `${TASK}.in_progress`, label: 'Overridden' }, { object: TASK, name: DEFAULT, label: 'All Tasks' }], + }, + }, + { + shape: 'a container under its object\'s name', + ruled: 'a container under its object\'s name (allowed)', + save: { name: LEAD, item: storedLead }, + allowed: { + because: 'the name the container contract gives it (ADR-0017 §3.2), and its expansion takes no name served elsewhere', + serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'All Leads' }, { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }], + }, + }, + { + shape: 'a container\'s own re-save', + given: [{ name: LEAD, item: storedLead }], + save: { name: LEAD, item: { ...storedLead, list: leadList('All Leads, edited') } }, + allowed: { + because: 'the row under the save name is the row this save replaces, never its own sibling', + serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'All Leads, edited' }, { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }], + }, + }, + { + shape: 'a container under a name of its own beside a sibling of its object, expanding names the sibling does not', + given: [{ name: LEAD, item: storedLead }], + save: { name: 'lead_hot_views', item: { object: LEAD, listViews: { hot: leadList('Hot Leads') } } }, + allowed: { + because: 'the census writers\' shape this door keeps (a container saved under a name other than its object), and its names collide with nothing', + serves: [ + { object: LEAD, name: `${LEAD}.hot`, label: 'Hot Leads' }, + { object: LEAD, name: `${LEAD}.default`, label: 'All Leads' }, + { object: LEAD, name: `${LEAD}.pipeline`, label: 'Lead Pipeline' }, + ], + }, + }, + { + shape: 'an overlay of the package\'s own container, by its own name', + save: { name: TASK, item: { name: TASK, list: taskList('Overlay'), listViews: { in_progress: taskList('Overlay In Progress') } } }, + allowed: { + because: 'ADR-0005 keys an overlay by its own name: the row stands in for the shipped container, and its views for that container\'s', + serves: [ + { object: TASK, name: DEFAULT, label: 'Overlay' }, + { object: TASK, name: `${TASK}.in_progress`, label: 'Overlay In Progress' }, + { object: TASK, name: KANBAN, label: 'Board' }, + ], + }, + }, + { + shape: 'a package-less container on another package\'s object, under a name of its own', + save: { name: OWN, item: { object: TASK, list: taskList('Probe') } }, + allowed: { + because: '#21334\'s arm: every name it expands derives from its own name, so none is a name the owning package ships', + serves: [{ object: TASK, name: `${TASK}.${OWN}`, label: 'Probe' }, { object: TASK, name: DEFAULT, label: 'All Tasks' }], + }, + }, + { + shape: 'a container in a writable package on another package\'s object, under a name of its own', + save: { name: OWN, item: { object: TASK, list: taskList('Probe') }, packageId: REPAIR }, + allowed: { + because: '#21334\'s arm, bound to its own package: its names derive from its own name', + serves: [{ object: TASK, name: `${TASK}.${OWN}`, label: 'Probe' }, { object: TASK, name: DEFAULT, label: 'All Tasks' }], + }, + }, + { + shape: 'a container saved under the expanded name of ANOTHER organization\'s container', + scopes: ['organization-scoped'], + given: [{ name: LEAD, item: storedLead, organizationId: OTHER_ORG }], + save: { name: `${LEAD}.pipeline`, item: { object: LEAD, list: leadList('Mine') } }, + allowed: { + because: 'the caller\'s selection decides, as it does for the readers: another organization\'s row is not in it', + serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'Mine' }], + }, + }, + { + shape: 'an environment-wide container saved under the expanded name of an organization\'s container', + scopes: ['environment-wide'], + given: [{ name: LEAD, item: storedLead, organizationId: ORG }], + save: { name: `${LEAD}.pipeline`, item: { object: LEAD, list: leadList('Everyone') } }, + allowed: { + because: 'the caller\'s selection decides: an organization\'s rows are not an environment-wide caller\'s, and reading every organization\'s rows at an environment-wide save would be a cross-tenant read at a write door', + serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'Everyone' }], + }, + }, + ]; + + /** The ruling's acceptance list, verbatim in substance: each must be a row. */ + const RULED = [ + '#21558\'s own-expansion name', + '#21620\'s sibling expansion of the same object', + '(1)\'s other-object container', + '(1)\'s unbound container', + '(2)\'s second container default', + '#21638\'s shipped item name', + 'a view item under an expanded name (allowed)', + 'a container under its object\'s name (allowed)', + ] as const; + /** Every cell of the predicate: which name collides, with which owner. */ + const CELLS: ReadonlyArray = [ + ['its save name', 'its own expansion'], + ['its save name', 'another stored container'], + ['its save name', 'a view item a package ships'], + ['a name its expansion produces', 'another stored container'], + ['a name its expansion produces', 'a view item a package ships'], + ]; + /** How the refusal names each kind of owner. */ + const OWNER_NAMED: Record string> = { + 'its own expansion': (object) => `its own expansion produces (its list view on '${object}')`, + 'another stored container': (container) => `the stored container '${container}'`, + 'a view item a package ships': (packageId) => `the package '${packageId}' ships`, + }; + + it('every row is either REFUSED, naming its owner, or ALLOWED, with its reason — never both, never neither', () => { + for (const row of SHAPES) { + expect([row.refused !== undefined, row.allowed !== undefined].filter(Boolean), row.shape).toHaveLength(1); + if (row.allowed) { + expect(row.allowed.because.trim().length, `${row.shape}: the reason`).toBeGreaterThan(0); + expect(row.allowed.serves.length, `${row.shape}: what it serves`).toBeGreaterThan(0); + } + if (row.refused) expect(row.refused.ownerName.length, `${row.shape}: the owner`).toBeGreaterThan(0); + } + expect(new Set(SHAPES.map((row) => row.shape)).size, 'one row per shape').toBe(SHAPES.length); + }); + + it('the ruling\'s shapes each have exactly one row, and every cell of the predicate has a refused row', () => { + expect([...RULED].sort()).toEqual(SHAPES.flatMap((row) => (row.ruled ? [row.ruled] : [])).sort()); + for (const [subject, owner] of CELLS) { + expect( + SHAPES.filter((row) => row.refused?.subject === subject && row.refused.owner === owner).length, + `a refused row for ${subject} × ${owner}`, + ).toBeGreaterThan(0); + } + expect( + SHAPES.filter((row) => row.refused).every((row) => CELLS.some(([s, o]) => s === row.refused!.subject && o === row.refused!.owner)), + 'every refused row sits in a cell of the predicate', + ).toBe(true); + }); + + const writeIn = (protocol: Protocol, write: Write, callerOrganizationId: string | undefined, mode?: 'draft' | 'publish') => { + const organizationId = write.organizationId === undefined ? callerOrganizationId : (write.organizationId ?? undefined); + return protocol.saveMetaItem({ + type: 'view', name: write.name, item: write.item, + ...(write.packageId ? { packageId: write.packageId } : {}), + ...scoped(organizationId), + ...(mode ? { mode } : {}), + } as any); + }; + const refusalOf = (write: Promise) => write.then(() => null, (e: any) => e); + const viewRowsOf = (rows: Map) => + [...rows.values()].filter((r) => r.type === 'view').map((r) => [r.name, r.organization_id, r.state, r.metadata]).sort(); + /** `name` answers one view item carrying `label` on BOTH doors. */ + const expectServed = async (protocol: Protocol, served: Served, organizationId: string | undefined) => { + const listed = named( + switcherMatches(((await protocol.getMetaItems({ type: 'view', ...scoped(organizationId) } as any)) as any).items, served.object), + served.name, + ); + expect(listed.map((v) => v.label), `${served.name} on the object door`).toEqual([served.label]); + const read = await byNameDoor(protocol, served.name, organizationId); + expect(isAggregatedViewContainer(read), `${served.name} by name is a view item, not a raw container`).toBe(false); + expect(read?.label, `${served.name} by name`).toBe(served.label); + }; + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const organizationId of [undefined, ORG]) { + const scope: Scope = organizationId ? 'organization-scoped' : 'environment-wide'; + for (const row of SHAPES) { + if (row.scopes && !row.scopes.includes(scope)) continue; + it(`${scope}: ${row.shape} — ${row.refused ? 'REFUSED' : 'ALLOWED'}`, async () => { + const { protocol, rows, registry } = collisionHarness(environmentId); + for (const given of row.given ?? []) { + expect(((await writeIn(protocol, given, organizationId)) as any)?.success, `${given.name} is stored first`).toBe(true); + } + if (row.allowed) { + expect(((await writeIn(protocol, row.save, organizationId)) as any)?.success, 'the save is accepted').toBe(true); + for (const served of row.allowed.serves) await expectServed(protocol, served, organizationId); + return; + } + const refused = row.refused!; + const storedBefore = viewRowsOf(rows); + const registeredBefore = JSON.stringify(registry.listItems('view')); + for (const mode of ['publish', 'draft'] as const) { + const error = await refusalOf(writeIn(protocol, row.save, organizationId, mode)); + // The ADR-0112 envelope … + expect(error, `${mode}: the save is refused`).toBeInstanceOf(Error); + expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + // … the save name, the colliding name and the other owner … + expect(error.message, 'the refusal names the save name').toContain(`'${row.save.name}'`); + expect(error.message, 'the refusal names the colliding name').toContain(`'${refused.collides}'`); + expect(error.message, 'the refusal names the other owner').toContain(OWNER_NAMED[refused.owner](refused.ownerName)); + // … and never prescribes a save under a name another stored row holds. + for (const given of row.given ?? []) { + expect(error.message, `no arm prescribes a save under '${given.name}'`).not.toContain(`under '${given.name}'`); + } + } + expect(viewRowsOf(rows), 'no row and no draft is stored').toEqual(storedBefore); + expect(JSON.stringify(registry.listItems('view')), 'nothing is registered').toBe(registeredBefore); + if (refused.stillServed) { + await expectServed(protocol, refused.stillServed, organizationId); + } + }); + } + } + }); + } + }); + + /** + * #21638's attribution half, folded into #21639: a package-less view + * container ROW stored under the name of a view ITEM a package ships is + * not that item's overlay — a container is not a view — so it belongs to + * no package. + * + * Measured on `origin/main` before this change (`7b07749f05`), with these + * rows written straight to the store (the save door refuses the shape + * now; these stand for rows stored before it did): `runtimeViewContainerPackage` + * read the row's package from the shipped item of the same name, so the + * container was judged the shipping package's own and expanded under that + * package's names — its bare `list` replaced the packaged + * `showcase_task.default` on both doors, wearing the package's + * `_packageId`, and a `listViews.edit` member replaced the packaged + * `showcase_task.edit`. Read now, each is a package-less container on + * another package's object, expanded under its own name (#21334's arm), + * with no re-save. The control is the overlay path that DOES take a + * package: a package-less row of the package's own container name. + */ + describe('#21638 a package-less container row stored under a shipped view item\'s name belongs to no package', () => { + const AT_REST = [ + { + member: 'a bare list', rowName: `${TASK}.in_progress`, body: { object: TASK, list: listView }, + spared: DEFAULT, servedAs: `${TASK}.${TASK}.in_progress`, authored: listView, + }, + { + member: 'listViews.edit', rowName: `${TASK}.in_progress`, body: { object: TASK, listViews: { edit: listView } }, + spared: `${TASK}.edit`, servedAs: `${TASK}.${TASK}.in_progress.edit`, authored: listView, + }, + ] as const; + const storeAtRest = (rows: Map, name: string, body: Record, organizationId?: string) => + rows.set(`at-rest:${name}:${organizationId ?? 'env'}`, { + id: `r_${name}`, type: 'view', name, organization_id: organizationId ?? null, + package_id: null, state: 'active', metadata: JSON.stringify({ name, ...body }), + }); + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const organizationId of [undefined, ORG]) { + const scope = organizationId ? 'organization-scoped' : 'environment-wide'; + for (const c of AT_REST) { + it(`${scope}, ${c.member} stored at ${c.rowName}: ${c.spared} still answers the packaged view on both doors, and the container's view is served under its own name, with no package and no default`, async () => { + const { protocol, rows } = showcaseHarness(environmentId); + storeAtRest(rows, c.rowName, c.body, organizationId); + + const shipped = PACKAGED.find((v) => v.name === c.spared)!; + const served = await objectDoor(protocol, organizationId); + const listed = named(served, c.spared); + expect(listed, `exactly one item answers ${c.spared} on the object door`).toHaveLength(1); + expect({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + const read = await byNameDoor(protocol, c.spared, organizationId); + expect({ label: read?.label, config: read?.config, _packageId: read?._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + + const own = named(served, c.servedAs); + expect(own, `the container's view answers ${c.servedAs}`).toHaveLength(1); + expect(own[0].config).toEqual(c.authored); + expect(own[0]._packageId, 'the row belongs to no package').toBeUndefined(); + expect(own[0]._provenance).not.toBe('package'); + expect(own[0].isDefault).toBeUndefined(); + expectOnlyPackagedDefaults(served); + }); + } + + it(`${scope}: CONTROL — a package-less row of the package's own container name is that container's overlay and keeps its package`, async () => { + const { protocol, rows } = showcaseHarness(environmentId); + storeAtRest(rows, TASK, { list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] } }, organizationId); + + const listed = named(await objectDoor(protocol, organizationId), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Customized'); + expect(listed[0]._packageId).toBe(SHOWCASE); + expect(listed[0].isDefault).toBe(true); + expect((await byNameDoor(protocol, DEFAULT, organizationId))?.label).toBe('Customized'); + }); + } + }); + } + }); }); /** From 0af0f28e49a4ebc04e32c82f0e95356ecf80ee85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:07:20 +0000 Subject: [PATCH 3/7] test(metadata-protocol): the enumeration pin reads every stored row's name as the container, never as a name to save under; #21558's pins name the shipping package Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 92b8c94848c..4ef8d5a0a6d 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1216,12 +1216,17 @@ describe('#21334 a container on another package\'s object never takes that packa protocol: Protocol, name: string, item: unknown, organizationId?: string, mode?: 'draft' | 'publish', ) => protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId), ...(mode ? { mode } : {}) } as any); const refusalOf = (write: Promise) => write.then(() => null, (e: any) => e); - /** The minimum a rejection pin asserts — the ADR-0112 envelope — plus the subjects it names. */ + /** + * The minimum a rejection pin asserts — the ADR-0112 envelope — plus the subjects it names. + * [#21639] Every name below is also a name the showcase SHIPS, and the family's one + * predicate names that other owner: the shipping package, and the object its view is on. + */ const expectRefused = (error: any, saveName: string) => { expect(error).toBeInstanceOf(Error); expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); expect(error.message, 'the refusal names the save name').toContain(`'${saveName}'`); - expect(error.message, 'the prescription names the object\'s own name').toContain(`'${TASK}'`); + expect(error.message, 'the refusal names the object its view is on').toContain(`'${TASK}'`); + expect(error.message, 'the refusal names the other owner, the shipping package').toContain(`the package '${SHOWCASE}' ships`); }; /** The doors answer `name` with the one item `expectItem` names, and the same item on both. */ const expectBothDoors = async ( @@ -1321,6 +1326,10 @@ describe('#21334 a container on another package\'s object never takes that packa * item under an expanded name still saves. One more control is the * census's: a container under a name of its own, not its object's and not * a sibling's expansion, still saves. + * + * [#21639] Since generalised: the family's one predicate drops "of the + * same object" and adds the shapes this check left open (that block, + * below). Every pin here keeps its envelope and its intent. */ describe('#21620 the save door refuses a container saved under a name another stored container of the same object expands to', () => { const LEAD = 'crm_lead'; @@ -1882,9 +1891,15 @@ describe('#21334 a container on another package\'s object never takes that packa expect(error.message, 'the refusal names the save name').toContain(`'${row.save.name}'`); expect(error.message, 'the refusal names the colliding name').toContain(`'${refused.collides}'`); expect(error.message, 'the refusal names the other owner').toContain(OWNER_NAMED[refused.owner](refused.ownerName)); - // … and never prescribes a save under a name another stored row holds. + // … and never prescribes a save under a name another stored row + // holds: wherever a stored row's name appears, it is named as THE + // CONTAINER (or as the object a view binds to), never as a name. for (const given of row.given ?? []) { - expect(error.message, `no arm prescribes a save under '${given.name}'`).not.toContain(`under '${given.name}'`); + const leadIns = String(error.message).split(`'${given.name}'`).slice(0, -1); + expect( + leadIns.filter((leadIn) => !/(container|on) $/.test(leadIn)), + `'${given.name}', a stored row's name, is never prescribed as a name to save under`, + ).toEqual([]); } } expect(viewRowsOf(rows), 'no row and no draft is stored').toEqual(storedBefore); From 1dfb40e4eae07803bbca8b620cb7372c16f0dbb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:12:11 +0000 Subject: [PATCH 4/7] fix(metadata-protocol): a shipped view item is one with viewKind set, asked positively by the save door's shipped arm and the container row's package attribution Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 31 ++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e5765e5ba0f..8d7a98a97cd 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1737,6 +1737,19 @@ function stateTenantAuthorship(data: unknown): unknown { return { ...(data as Record), _provenance: 'org' }; } +/** + * [#21639, #21638] Is this artifact a view ITEM — a view a package ships under + * its own name, `viewKind` set (the spec's ViewItem) — rather than a container + * or anything else? The save door's "a view item a package ships" and the + * package attribution of a container row both ask exactly this, positively: + * a body that is neither a container nor a view item is not a view item. + */ +function isShippedViewItem(artifact: unknown): artifact is Record { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return false; + const viewKind = (artifact as { viewKind?: unknown }).viewKind; + return typeof viewKind === 'string' && viewKind !== ''; +} + /** * ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list. * @@ -17055,11 +17068,12 @@ export class ObjectStackProtocolImplementation implements * the container it overlays, which is the slot the package-aware merge * seats it in. `undefined` for a package-less row that overlays nothing. * - * [#21638] Only a shipped CONTAINER is something a container row - * overlays. A container row stored under the name of a view ITEM a - * package ships is not that item's overlay — a container is not a view — - * so it belongs to no package, and its expansion is placed as any - * package-less container's is. Read through the shipped item instead, + * [#21638] A container row is not the overlay of a view ITEM. A + * package-less container row stored under the name of a view item a + * package ships (`viewKind` set, {@link isShippedViewItem}) is not that + * item's overlay — a container is not a view — so it belongs to no + * package, and its expansion is placed as any package-less container's + * is. The overlay of a shipped container keeps its package, as before. Read through the shipped item instead, * such a row was judged a container of the shipping package: on that * package's object its bare `list` took `.default` and replaced * the packaged default on both doors, wearing the package's `_packageId`, @@ -17077,7 +17091,7 @@ export class ObjectStackProtocolImplementation implements } if (typeof container.name !== 'string' || container.name === '') return undefined; const overlaid = this.lookupArtifactItem(type, container.name) as { _packageId?: unknown } | undefined; - if (!isAggregatedViewContainer(overlaid)) return undefined; + if (isShippedViewItem(overlaid)) return undefined; const overlaidPackage = overlaid?._packageId; return typeof overlaidPackage === 'string' && overlaidPackage !== '' ? overlaidPackage : undefined; } @@ -17929,7 +17943,8 @@ export class ObjectStackProtocolImplementation implements * own sibling; * - a view item a package ships ({@link lookupArtifactItem}, the * registry's artifact read, which never answers a tenant-authored - * row), except the views of the shipped container this row overlays + * row, holding a view item: {@link isShippedViewItem}), except the + * views of the shipped container this row overlays * by its own name ({@link overlaidShippedContainerViewNames}): ADR-0005 * keys an overlay by its own name, so an overlay of a package's * container stands in for that container and its views. @@ -18000,7 +18015,7 @@ export class ObjectStackProtocolImplementation implements const shippedServing = (name: string): Record | undefined => { if (overlaid.has(name)) return undefined; const artifact = this.lookupArtifactItem(type, name) as Record | undefined; - if (!artifact || isAggregatedViewContainer(artifact)) return undefined; + if (!isShippedViewItem(artifact)) return undefined; return typeof artifact._packageId === 'string' && artifact._packageId !== '' ? artifact : undefined; }; const refusal = (text: string) => { From 082a6f291897112f06534dc95027b0a3712a5ee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:16:28 +0000 Subject: [PATCH 5/7] fix(metadata-protocol): the shipped-view-item test is a boolean, so it narrows nothing it does not hold Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8d7a98a97cd..98d8d9f999e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1744,7 +1744,7 @@ function stateTenantAuthorship(data: unknown): unknown { * package attribution of a container row both ask exactly this, positively: * a body that is neither a container nor a view item is not a view item. */ -function isShippedViewItem(artifact: unknown): artifact is Record { +function isShippedViewItem(artifact: unknown): boolean { if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return false; const viewKind = (artifact as { viewKind?: unknown }).viewKind; return typeof viewKind === 'string' && viewKind !== ''; @@ -18015,7 +18015,7 @@ export class ObjectStackProtocolImplementation implements const shippedServing = (name: string): Record | undefined => { if (overlaid.has(name)) return undefined; const artifact = this.lookupArtifactItem(type, name) as Record | undefined; - if (!isShippedViewItem(artifact)) return undefined; + if (!artifact || !isShippedViewItem(artifact)) return undefined; return typeof artifact._packageId === 'string' && artifact._packageId !== '' ? artifact : undefined; }; const refusal = (text: string) => { From 34e9026234643e3792b7bdf31fa9605366344ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:18:39 +0000 Subject: [PATCH 6/7] chore(changeset): the save door's one view container collision predicate, and the package-less container row's attribution Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../21639-view-container-name-collision.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/21639-view-container-name-collision.md diff --git a/.changeset/21639-view-container-name-collision.md b/.changeset/21639-view-container-name-collision.md new file mode 100644 index 00000000000..6afe55ab1e6 --- /dev/null +++ b/.changeset/21639-view-container-name-collision.md @@ -0,0 +1,28 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +The runtime save door refuses a view container whose save name, or any name its expansion produces, is a name already served from elsewhere; a package-less container row named after a view item a package ships belongs to no package + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier container-name refusals shipped with. + +**One rule.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, now refuses an aggregated view container (`list` / `form` / `listViews` / `formViews`) when its save name, or any name its expansion produces, is already served from elsewhere: by another stored container's expansion in the caller's selection (environment-wide rows plus the caller's organization's), whatever that container's object, or by a view item (a body carrying `viewKind`) a package ships. A name the container's own expansion produces is refused as its save name too. Every name is judged where the read doors place it, so every member kind, the expander's de-duplicated names and a container on another package's object (which expands under its own name) are all covered. The refusal is `VALIDATION_ERROR` / 400, in draft and in publish mode, before anything is stored or registered, and it names the other owner: the stored container, the shipping package, or the container's own expansion. + +**Before and after, per shape** (with `{ name: 'crm_lead', object: 'crm_lead', list, listViews: { pipeline } }` stored where a sibling is named): + +- A container bound to **another object**, saved under a sibling's expanded name (`{ object: 'crm_account', list }` as `crm_lead.pipeline`). Before: accepted; the sibling's `crm_lead.pipeline` view was no longer served on either door, and the by-name read answered the raw container. After: refused, naming the container `crm_lead`. +- An **unbound** container (`{ list }`) under the same name. Before and after: as above. +- A **second container of one object** whose bare `list` takes `crm_lead.default`, under a free name (`{ object: 'crm_lead', list }` as `lead_other_views`), or the object-named container saved after such a one. Before: accepted; whichever container was read last replaced the other's default on both doors, and nothing said why. After: refused, naming the stored container that already serves the name. +- A container saved under the name of a **view item a package ships** (`{ name: 'showcase_task.in_progress', object: 'showcase_task', list }` as `showcase_task.in_progress`), package-less, organization-scoped or in a writable package. Before: accepted; the packaged view was no longer served on the object door and the by-name read answered the raw container. Package-less, the row was also judged a container of the shipping package, so its bare `list` replaced the packaged `showcase_task.default` on both doors, wearing that package's `_packageId`. After: refused, naming the shipping package. +- An overlay of a package's own container whose new member takes the name of a view item **the package ships on its own**. Before: accepted; the member replaced that packaged view on both doors. After: refused, naming the package. +- A container under a name its own expansion produces, or under a name another stored container of the same object expands. Refused before and after, with the same envelope. The own-expansion refusal no longer tells the author to save the container under its object's name when a stored container already holds that name; it names that container to add the view to. + +**What still saves.** A view item under any of these names: it is that name's sanctioned override. A container under its object's name, or under any other name of its own, whose expansion takes no name served elsewhere, and its own re-save. An overlay of a package's own container under that container's name. A container on another package's object, which expands under its own name. A container whose would-be sibling is in another organization: the caller's own selection decides, as it does for the read doors. + +**Rows stored before this change.** They keep their bytes and are served as before, with one change: a package-less container row stored under the name of a view item a package ships now belongs to no package. On that package's object it expands under its own name (`showcase_task.showcase_task.in_progress` for a bare `list`), with no `_packageId` and no default, and the packaged views it used to replace are served again on both doors. The row itself still takes its own name's slot, as any stored row does. A new save of a row in a refused shape, a re-save included, is refused until its body stops colliding; `migrate meta --stored` and package duplication report such a row as failed with this refusal instead of re-saving it. Delete stays open. + +**The fix.** Add the view as a member of the stored container that already serves the name (its `list`, `listViews`, `form` or `formViews`), or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override it. For a name a package ships: save a view item under it to override the packaged view, or save the container under a name of its own that no package ships and no stored container expands. From e5ac5cf14aad9172cd06ec17987f7e02f0ec4da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:30:20 +0000 Subject: [PATCH 7/7] fix(metadata-protocol): every container collision refusal names its owner and its prescription inside the 500-character wire bound, the explanation after Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 44 ++++++++++--------- .../view-container-runtime-expansion.test.ts | 7 +++ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 98d8d9f999e..f853b383e1b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18030,25 +18030,29 @@ export class ObjectStackProtocolImplementation implements `Add the view as a member of the container '${container}' (its list, listViews, form or formViews)`; const ofItsOwn = 'the container under a name of its own that no package ships and no stored container expands'; + // ⛔ Every message names the owner, then gives the prescription, and + // only then explains: a 4xx message crosses the REST boundary bounded + // at 500 characters by truncating its TAIL (`CLIENT_MESSAGE_MAX`), so + // the explanation is the half an author can lose and still act. + // // The save name: the row this container would be. const rowHides = 'this container would be that row, so'; const sibling = siblingServing(saveName); if (sibling) { return refusal( `it is saved under '${saveName}', which is a name the stored container '${sibling.container.name}' ` - + `expands (its ${kindOn(sibling.item)}). An expanded view fills only a name that has no stored row of ` - + `its own, and ${rowHides} that view would no longer be served and no read would answer a view under ` - + `'${saveName}'. ${asMemberOf(sibling.container.name)}, or save ${viewItem(saveName)}.`, + + `expands (its ${kindOn(sibling.item)}). ${asMemberOf(sibling.container.name)}, or save ` + + `${viewItem(saveName)}. An expanded view fills only a name that has no stored row of its own, and ` + + `${rowHides} that view would no longer be served and no read would answer a view under '${saveName}'.`, ); } const shipped = shippedServing(saveName); if (shipped) { return refusal( `it is saved under '${saveName}', which is the name of the ${kindOn(shipped)} the package ` - + `'${String(shipped._packageId)}' ships. A stored row under a packaged view's name takes that view's ` - + `place, and ${rowHides} the packaged view would no longer be served and no read would answer a view ` - + `under '${saveName}'. Save ${viewItem(saveName)} to override the packaged view, or save ` - + `${ofItsOwn}.`, + + `'${String(shipped._packageId)}' ships. Save ${viewItem(saveName)} to override the packaged view, or ` + + `save ${ofItsOwn}. A stored row under a packaged view's name takes that view's place, and ${rowHides} ` + + `the packaged view would no longer be served and no read would answer a view under '${saveName}'.`, ); } const own = served.find((expanded) => expanded.name === saveName); @@ -18061,33 +18065,33 @@ export class ObjectStackProtocolImplementation implements ? asMemberOf(object) : `Save ${ofItsOwn}`; return refusal( - `it is saved under '${saveName}', which is a name its own expansion produces (its ${kindOn(own)}). An ` - + `expanded view fills only a name that has no stored row of its own, and ${rowHides} no read would ` - + `answer a view under '${saveName}'. ${firstArm}, or save ${viewItem(saveName)}.`, + `it is saved under '${saveName}', which is a name its own expansion produces (its ${kindOn(own)}). ` + + `${firstArm}, or save ${viewItem(saveName)}. An expanded view fills only a name that has no stored ` + + `row of its own, and ${rowHides} no read would answer a view under '${saveName}'.`, ); } // Every name its expansion produces: the views this container would serve. for (const view of served) { const name = String(view.name); - const taken = `it is saved under '${saveName}', and its ${kindOn(view)} would be served as '${name}'`; + const taken = `its ${kindOn(view)} would be served as '${name}'`; const other = siblingServing(name); if (other) { return refusal( - `${taken}, which is a name the stored container '${other.container.name}' already expands (its ` - + `${kindOn(other.item)}). Two containers cannot serve one name: the one read last would replace ` - + `the other's view on both doors, and nothing would say why. ${asMemberOf(other.container.name)}, ` - + `or save ${viewItem(name)}.`, + `${taken}, a name the stored container '${other.container.name}' already expands. ` + + `${asMemberOf(other.container.name)}, or save ${viewItem(name)}. Saved under '${saveName}', this ` + + `container and that one would both serve '${name}': the one read last would replace the other's ` + + `view on both doors, and nothing would say why.`, ); } const packaged = shippedServing(name); if (packaged) { return refusal( - `${taken}, which is the name of the ${kindOn(packaged)} the package ` - + `'${String(packaged._packageId)}' ships. A container's view under a packaged view's name ` - + `replaces that view on both doors, and nothing would say why. Save ${viewItem(name)} to override ` - + `the packaged view, or give the member a key of its own that no package ships and no stored ` - + `container expands.`, + `${taken}, the name of the ${String(packaged.viewKind)} view the package ` + + `'${String(packaged._packageId)}' ships. Save ${viewItem(name)} to override the packaged view, or ` + + `give the member a key of its own that no package ships and no stored container expands. Saved ` + + `under '${saveName}', this container's view would replace the packaged view on both doors, and ` + + `nothing would say why.`, ); } } 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 4ef8d5a0a6d..4ea1ae98c64 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1891,6 +1891,13 @@ describe('#21334 a container on another package\'s object never takes that packa expect(error.message, 'the refusal names the save name').toContain(`'${row.save.name}'`); expect(error.message, 'the refusal names the colliding name').toContain(`'${refused.collides}'`); expect(error.message, 'the refusal names the other owner').toContain(OWNER_NAMED[refused.owner](refused.ownerName)); + // … the owner and the prescription both inside the first 500 characters, + // the bound a 4xx message crosses the REST boundary under, tail cut + // (`CLIENT_MESSAGE_MAX`): the explanation is the half that may be lost … + const delivered = String(error.message).slice(0, 500); + expect(delivered, 'the owner survives the wire bound').toContain(OWNER_NAMED[refused.owner](refused.ownerName)); + expect(delivered, 'the prescription survives the wire bound') + .toContain(`a view item (name, object, viewKind and config) under '${refused.collides}'`); // … and never prescribes a save under a name another stored row // holds: wherever a stored row's name appears, it is named as THE // CONTAINER (or as the object a view binds to), never as a name.