From 694f26527583ef76cceed2853d6a9ba64fb0f532 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:48:20 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): the list read's expansion never displaces a stored row of the same name The object door (getMetaItems, readFlattenedMetaItems) upserted every name a stored view container expands over the merged items, a stored row of exactly that name included, while the by-name read answered the row. Both doors now ask one predicate, namesWithOwnStoredRow, over the rows they select for the caller: an expansion fills only a name with no stored row of its own. The by-name read's predicate is factored out unchanged; the list read now asks it. Pins: the dev's setup (a stored overlay of showcase_task's container plus a stored row named showcase_task.default) on both kernels, both scopes and both write orders, with the row-less expanded name as the control, and a row stored for one organization answering for that organization only. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .changeset/21510-list-read-stored-row-wins.md | 11 ++ packages/metadata-protocol/src/protocol.ts | 40 +++++- .../view-container-runtime-expansion.test.ts | 114 ++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 .changeset/21510-list-read-stored-row-wins.md diff --git a/.changeset/21510-list-read-stored-row-wins.md b/.changeset/21510-list-read-stored-row-wins.md new file mode 100644 index 00000000000..9bd9dbd4d61 --- /dev/null +++ b/.changeset/21510-list-read-stored-row-wins.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): the object door lists a stored view row under its own name even where a stored view container expands that name, as the by-name read already answers + +Clause-②: no + +- **What changed.** `GET /api/v1/meta/view?object=…` (the object door) no longer lets a stored view container's expansion replace a stored row of the same name. A row saved under a name the container also expands, such as `.default` beside a stored overlay of that object's container, is now what the object door lists under that name. Before, the object door listed the container's expansion there while the by-name read (`GET /api/v1/meta/view/NAME`) answered the stored row. Both doors now answer the row. +- **The rule.** A row stored under exactly a name is the override for that name (ADR-0005 keys an overlay by its own name). An expansion fills only the names that have no row of their own. The list read and the by-name read decide this with one test, over the rows each selects for the same caller, so a row stored for one organization does not hide the expansion from any other caller. +- **What does not change.** Every other name a container expands is still listed, and on both doors it still replaces a packaged view of the same name. The by-name read answers as before. The save door is unchanged. No response shape gains or loses a key. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c41476c3401..f1622d5bdde 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8352,12 +8352,23 @@ export class ObjectStackProtocolImplementation implements // another package's object every name a container expands // derives from the container's own name, never one of that // package's `.` names. + // + // [#21510] …and it never replaces a STORED ROW of that name. + // A row stored under exactly the name is the sanctioned + // override for it (ADR-0005 keys an overlay by its own name); + // an expansion fills only a name with no row of its own. The + // test is {@link namesWithOwnStoredRow} over this caller's + // `records`, the one the by-name read asks, so the two doors + // answer the same row for the name. An item the registry or a + // package supplies under the name is still replaced, as before. if (isView) { const byName = new Map(); for (const it of items as any[]) { if (it && typeof it === 'object' && typeof it.name === 'string') byName.set(it.name, it); } + const ownRowNames = this.namesWithOwnStoredRow(records); for (const { item: vi } of this.expandStoredViewContainers(request.type, overlays)) { + if (ownRowNames.has(vi.name as string)) continue; byName.set(vi.name as string, vi); } items = Array.from(byName.values()); @@ -8818,6 +8829,30 @@ export class ObjectStackProtocolImplementation implements return out; } + /** + * [#21510] The names that have a stored row of their own among `records`, + * the active rows {@link readActiveOverlayRows} selected for one caller. + * + * ADR-0005 keys an overlay by its own name, so a row stored under exactly a + * name is the sanctioned override for that name. An expansion is derived + * from its container, so it fills only a name that is NOT in this set. This + * is the one predicate both doors ask: the list read + * ({@link readFlattenedMetaItems}) never lets an expansion displace a + * stored row of the same name, and the by-name read + * ({@link resolveRowlessExpandedView}) answers an expansion only for a name + * outside it. Both doors pass the rows they selected for the same caller, + * so a name that has a row in one organization only is row-less for every + * other caller. ⛔ Never a second test of "this name has its own row": + * two tests are two rules, and the doors would disagree again. + */ + private namesWithOwnStoredRow(records: readonly any[]): ReadonlySet { + const names = new Set(); + for (const record of records) { + if (typeof record?.name === 'string') names.add(record.name); + } + return names; + } + /** * [#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 @@ -8845,7 +8880,8 @@ export class ObjectStackProtocolImplementation implements * ⛔ 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` + * as such by the caller's own read, never an expansion — the same + * predicate the list read applies ({@link namesWithOwnStoredRow}). 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. @@ -8867,7 +8903,7 @@ export class ObjectStackProtocolImplementation implements // this name". this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - if (records.some((record) => record?.name === request.name)) return undefined; + if (this.namesWithOwnStoredRow(records).has(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; 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 26be98d5f97..99e6d08661a 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -940,6 +940,120 @@ describe('#21334 a container on another package\'s object never takes that packa } }); }); + + /** + * #21510 — a stored row named exactly like a name a stored container + * expands answers that name on BOTH doors. + * + * Triage's ruling: the stored row wins on both doors. ADR-0005 overlays are + * name-keyed, so a row stored under exactly that name is the sanctioned + * override for it; an expansion is derived from its container, so it fills + * only names that have no row of their own. That is the rule #21442 gave + * the by-name read, and the object door's list read adopts it: ⛔ not a + * second rule, both doors ask one predicate over the caller's own row + * selection. + * + * Measured on `origin/main` before this change, with this harness: the + * object door listed the container's expansion under the row's name + * (`FromContainer`) while the by-name read answered the stored row + * (`ByNameRow`), on both kernels, in both scopes and in either write + * order. The name the same container expands with no row of its own still + * answers the expansion on both doors — the control. + */ + describe('#21510 a stored row named exactly like an expansion answers that name on both doors', () => { + const withoutDiagnostics = (item: any) => { + if (!item || typeof item !== 'object') return item; + const { _diagnostics: _drop, ...rest } = item; + return rest; + }; + /** The dev's setup: a stored overlay of the showcase's own `showcase_task` container… */ + const container = { + name: TASK, + list: { label: 'FromContainer', type: 'grid', data, columns: [{ field: 'title' }] }, + listViews: { + in_progress: { label: 'FromContainer In Progress', type: 'grid', data, columns: [{ field: 'title' }] }, + }, + }; + /** …plus a stored row named exactly like the name its bare `list` expands to. */ + const row = { + name: DEFAULT, object: TASK, viewKind: 'list', label: 'ByNameRow', + config: { type: 'grid', data, columns: [{ field: 'title' }, { field: 'status' }] }, + }; + /** The control: a name the container expands that has no row of its own. */ + const ROWLESS = `${TASK}.in_progress`; + const saveView = (protocol: Protocol, name: string, item: unknown, organizationId?: string) => + protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId) } as any); + /** The two doors answer `name` with one item, and that item is the one `expectItem` names. */ + const expectBothDoors = async ( + protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void, + ) => { + const listed = named(await objectDoor(protocol, organizationId), name); + expect(listed, `exactly one item answers ${name} on the object door`).toHaveLength(1); + expectItem(listed[0]); + const read = await byNameDoor(protocol, name, organizationId); + expectItem(read); + expect(withoutDiagnostics(read), `${name}: the by-name read answers the item the object door lists`) + .toEqual(withoutDiagnostics(listed[0])); + }; + const expectTheRow = (v: any) => { + expect(v?.label).toBe('ByNameRow'); + expect(v?.config).toEqual(row.config); + }; + const expectTheExpansion = (v: any) => { + expect(v?.label).toBe('FromContainer In Progress'); + expect(v?.config?.columns).toEqual([{ field: 'title' }]); + }; + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const organizationId of [undefined, ORG]) { + const scope = organizationId ? 'organization-scoped' : 'environment-wide'; + for (const order of ['the container first', 'the row first'] as const) { + it(`${scope}, ${order}: the stored row answers its name on both doors; the row-less expanded name answers the expansion`, async () => { + const { protocol, rows } = showcaseHarness(environmentId); + const writes = [ + () => saveView(protocol, TASK, container, organizationId), + // The public input: the save door accepts a write by the expanded name. + () => saveView(protocol, DEFAULT, row, organizationId), + ]; + for (const write of order === 'the container first' ? writes : [...writes].reverse()) await write(); + expect( + [...rows.values()].filter((r) => r.name === DEFAULT && r.organization_id === (organizationId ?? null)), + 'the save door stored the row under the expanded name', + ).toHaveLength(1); + + await expectBothDoors(protocol, DEFAULT, organizationId, expectTheRow); + // CONTROL — a row-less name the same container expands. + await expectBothDoors(protocol, ROWLESS, organizationId, expectTheExpansion); + // The contract the ruling keeps (#21334): every name + // the object door lists answers that same item by name. + for (const listed of await objectDoor(protocol, organizationId)) { + const read = await byNameDoor(protocol, listed.name, organizationId); + expect(withoutDiagnostics(read), `${listed.name} by name`).toEqual(withoutDiagnostics(listed)); + } + }); + } + } + + it('the predicate is read over the caller\'s own rows: an organization\'s row wins for that organization only', async () => { + const { protocol } = showcaseHarness(environmentId); + await saveView(protocol, TASK, container); + await saveView(protocol, DEFAULT, row, ORG); + + // The organization that holds the row: the row, on both doors. + await expectBothDoors(protocol, DEFAULT, ORG, expectTheRow); + // A caller for whom the name has no row of its own: the + // container's expansion, on both doors. + const expectTheDefaultExpansion = (v: any) => { + expect(v?.label).toBe('FromContainer'); + expect(v?.config?.columns).toEqual([{ field: 'title' }]); + }; + await expectBothDoors(protocol, DEFAULT, undefined, expectTheDefaultExpansion); + await expectBothDoors(protocol, DEFAULT, 'org_globex', expectTheDefaultExpansion); + }); + }); + } + }); }); /** From 6a41000f1e73a0bf7df681d01c1f16a886481308 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:02:29 +0000 Subject: [PATCH 2/3] test(metadata-protocol): the stored row's name keeps its own history and diff beside an expansion of that name The by-name family (history, diff) asks the same own-row predicate the list read now asks, so a reversal of that predicate is observable on both doors. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/view-container-runtime-expansion.test.ts | 8 ++++++++ 1 file changed, 8 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 99e6d08661a..71add017eae 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1023,6 +1023,14 @@ describe('#21334 a container on another package\'s object never takes that packa ).toHaveLength(1); await expectBothDoors(protocol, DEFAULT, organizationId, expectTheRow); + // The by-name family asks the same predicate: the + // row's name keeps its own change log and its own + // diff, never the container's. + const events = (await protocol.historyMetaItem({ type: 'view', name: DEFAULT, ...scoped(organizationId) })).events; + expect(events.length, 'the row has a change log of its own').toBeGreaterThan(0); + expect(events.every((e: any) => e.ref.name === DEFAULT), 'every event names the row').toBe(true); + expect((await (protocol as any).diffMetaItem({ type: 'view', name: DEFAULT, ...scoped(organizationId) })).name) + .toBe(DEFAULT); // CONTROL — a row-less name the same container expands. await expectBothDoors(protocol, ROWLESS, organizationId, expectTheExpansion); // The contract the ruling keeps (#21334): every name From d12a8f62563dba5805ee8f95fca5dae6f25fd287 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:36:10 +0000 Subject: [PATCH 3/3] docs(changeset): name the container stored under one of its own expanded names A view item saved under an expanded name is what the object door now lists; a container stored under such a name is its own row too, so the object door (which never lists a container) lists nothing there. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .changeset/21510-list-read-stored-row-wins.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/21510-list-read-stored-row-wins.md b/.changeset/21510-list-read-stored-row-wins.md index 9bd9dbd4d61..e3a2e047aa2 100644 --- a/.changeset/21510-list-read-stored-row-wins.md +++ b/.changeset/21510-list-read-stored-row-wins.md @@ -6,6 +6,7 @@ fix(metadata-protocol): the object door lists a stored view row under its own na Clause-②: no -- **What changed.** `GET /api/v1/meta/view?object=…` (the object door) no longer lets a stored view container's expansion replace a stored row of the same name. A row saved under a name the container also expands, such as `.default` beside a stored overlay of that object's container, is now what the object door lists under that name. Before, the object door listed the container's expansion there while the by-name read (`GET /api/v1/meta/view/NAME`) answered the stored row. Both doors now answer the row. +- **What changed.** `GET /api/v1/meta/view?object=…` (the object door) no longer lets a stored view container's expansion replace a stored row of the same name. A view item (a row carrying `viewKind`) saved under a name the container also expands, such as `.default` beside a stored overlay of that object's container, is now what the object door lists under that name. Before, the object door listed the container's expansion there while the by-name read (`GET /api/v1/meta/view/NAME`) answered the stored row. Both doors now answer the row. - **The rule.** A row stored under exactly a name is the override for that name (ADR-0005 keys an overlay by its own name). An expansion fills only the names that have no row of their own. The list read and the by-name read decide this with one test, over the rows each selects for the same caller, so a row stored for one organization does not hide the expansion from any other caller. -- **What does not change.** Every other name a container expands is still listed, and on both doors it still replaces a packaged view of the same name. The by-name read answers as before. The save door is unchanged. No response shape gains or loses a key. +- **A container stored under one of its own expanded names.** That row is the name's own row as well, so its expansion no longer fills the name. The object door never lists a container, so it now lists nothing under that name. Before, it listed the container's expansion there. The by-name read answers the stored container, as before. +- **What does not change.** Every name a container expands that has no stored row of its own is still listed, and on both doors it still replaces a packaged view of the same name. The by-name read answers as before. The save door is unchanged. No response shape gains or loses a key.