From 83544ad4d3b14ae1698bc5c551568a7ff1251fee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:28:21 +0000 Subject: [PATCH 1/4] test(metadata-protocol): every read states the row's column binding (red on #28671's head) --- .../src/protocol.row-binding-column.test.ts | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.row-binding-column.test.ts diff --git a/packages/metadata-protocol/src/protocol.row-binding-column.test.ts b/packages/metadata-protocol/src/protocol.row-binding-column.test.ts new file mode 100644 index 00000000000..12099386691 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.row-binding-column.test.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28673] Every metadata read states the package a stored row's `package_id` + * COLUMN records, and none the column does not: a `_packageId` the row's body + * carries is replaced by the column's package, or dropped when the row is bound + * to no package. One rule, {@link withRowBinding}, the one the list read's + * `storedOverlayEntries` took first (#28594): the save door already strips a + * body's stamp (`stripDerivedProvenance`: the server states provenance, a + * caller never does), but a row at rest can still carry one, and the reads + * below used to stamp the column's package only while the body named none, so + * they echoed it. + * + * 1. The rule itself: replaced, dropped, kept, and a copy. + * 2. The by-name active read (and the strict draft read, the same site). + * 3. The by-name draft preview (`previewDrafts`). + * 4. The draft list (`getMetaItems` with `previewDrafts`). + * 5. The list merge (`mergePackageAwareOverlay`): a package-less row, served + * as itself or standing in for a package's slot. + * 6. Hydration (`hydrateOverlayIntoRegistry`): the registered body, and the + * items a view container row expands to. + * + * Each read has its control: an absent or agreeing stamp is served as before. + * The list read's own pins are `protocol-stored-overlay-reads.test.ts`; the + * scoped list's view-expansion stand-in (a package-less row the merge held + * back, served in a package's slot) is `protocol.scoped-list-fallback.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation, mergePackageAwareOverlay, withRowBinding } from './protocol.js'; + +const TYPE = 'dashboard'; +const NAME = 'd_bound'; +const HOST = 'com.acme.host'; +const PKG_A = 'com.acme.authored'; + +interface StoredRow { + id: string; + type: string; + name: string; + package_id: string | null; + state: string; + metadata: string; +} + +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + +/** A stored row of {@link NAME}: its column `packageId`, its body's own stamp `stamp` (absent when `undefined`). */ +function stored( + packageId: string | null, + stamp: string | undefined, + state: 'active' | 'draft' = 'active', + extra: Record = {}, +): StoredRow { + return { + id: `r_${packageId ?? 'none'}_${state}`, + type: TYPE, + name: NAME, + package_id: packageId, + state, + metadata: JSON.stringify({ + name: NAME, + label: `${state} row`, + ...(stamp === undefined ? {} : { _packageId: stamp }), + ...extra, + }), + }; +} + +/** The engine double: `find` / `findOne` over `sys_metadata`, and a registry holding nothing of the type. */ +function harness(rows: StoredRow[]) { + const registry = { + getArtifactItem: (_type: string, _name: string) => undefined, + getItem: () => undefined, + listItems: () => [], + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + async insert() { + return {}; + }, + registry, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1'); +} + +const byName = async (rows: StoredRow[], request: Record = {}): Promise => + ((await harness(rows).getMetaItem({ type: TYPE, name: NAME, ...request } as any)) as any).item; + +const listed = async (rows: StoredRow[], request: Record = {}): Promise => + (((await harness(rows).getMetaItems({ type: TYPE, ...request } as any)) as any).items as any[]) + .filter((item) => item?.name === NAME); + +// ── 1. The rule ───────────────────────────────────────────────────────────── + +describe('[#28673] 1. withRowBinding — the row\'s binding is its column\'s', () => { + it('a body stamp on a row bound to no package is dropped, on a copy', () => { + const body = { name: 'a', _packageId: HOST }; + for (const column of [undefined, null, '', 'sys_metadata']) { + const bound = withRowBinding(body, column) as Record; + expect(bound, String(column)).not.toHaveProperty('_packageId'); + expect(bound).not.toBe(body); + } + expect(body._packageId).toBe(HOST); + }); + + it('a body stamp naming another package is replaced by the column\'s, on a copy', () => { + const body = { name: 'a', _packageId: HOST }; + const bound = withRowBinding(body, PKG_A) as Record; + expect(bound._packageId).toBe(PKG_A); + expect(body._packageId).toBe(HOST); + }); + + it('control: an absent stamp is stated from the column, an agreeing one kept; a body that already says it is returned as it is', () => { + expect((withRowBinding({ name: 'a' }, PKG_A) as any)._packageId).toBe(PKG_A); + const agreeing = { name: 'a', _packageId: PKG_A }; + expect(withRowBinding(agreeing, PKG_A)).toBe(agreeing); + const unbound = { name: 'a' }; + expect(withRowBinding(unbound, null)).toBe(unbound); + // Not a record: nothing to state. + for (const v of [undefined, null, 'x', 3, [{ _packageId: HOST }]]) expect(withRowBinding(v, PKG_A)).toBe(v); + }); +}); + +// ── 2. The by-name active read ────────────────────────────────────────────── + +describe('[#28673] 2. the by-name active read serves the column\'s package', () => { + it('a package-less row whose body carries `_packageId` serves none', async () => { + const item = await byName([stored(null, HOST)]); + expect(item?.label).toBe('active row'); + expect(item).not.toHaveProperty('_packageId'); + }); + + it('a row whose body names another package serves the column\'s', async () => { + const item = await byName([stored(PKG_A, HOST)], { packageId: PKG_A }); + expect(item?.label).toBe('active row'); + expect(item?._packageId).toBe(PKG_A); + }); + + it('the strict draft read, the same site: a package-less draft whose body carries one serves none', async () => { + const item = await byName([stored(null, HOST, 'draft')], { state: 'draft' }); + expect(item?.label).toBe('draft row'); + expect(item).not.toHaveProperty('_packageId'); + const other = await byName([stored(PKG_A, HOST, 'draft')], { state: 'draft', packageId: PKG_A }); + expect(other?._packageId).toBe(PKG_A); + }); + + it('control: an absent or agreeing stamp is served as before', async () => { + expect((await byName([stored(PKG_A, undefined)], { packageId: PKG_A }))?._packageId).toBe(PKG_A); + expect((await byName([stored(PKG_A, PKG_A)], { packageId: PKG_A }))?._packageId).toBe(PKG_A); + expect(await byName([stored(null, undefined)])).not.toHaveProperty('_packageId'); + }); +}); + +// ── 3. The by-name draft preview ──────────────────────────────────────────── + +describe('[#28673] 3. the by-name draft preview serves the column\'s package', () => { + it('a package-less draft whose body carries `_packageId` previews none', async () => { + const item = await byName([stored(null, undefined), stored(null, HOST, 'draft')], { previewDrafts: true }); + expect(item?.label).toBe('draft row'); + expect(item?._draft).toBe(true); + expect(item).not.toHaveProperty('_packageId'); + }); + + it('a draft whose body names another package previews the column\'s', async () => { + const item = await byName([stored(PKG_A, HOST, 'draft')], { previewDrafts: true, packageId: PKG_A }); + expect(item?.label).toBe('draft row'); + expect(item?._packageId).toBe(PKG_A); + }); + + it('control: an absent or agreeing stamp previews as before', async () => { + expect((await byName([stored(PKG_A, undefined, 'draft')], { previewDrafts: true, packageId: PKG_A }))?._packageId).toBe(PKG_A); + expect((await byName([stored(PKG_A, PKG_A, 'draft')], { previewDrafts: true, packageId: PKG_A }))?._packageId).toBe(PKG_A); + expect(await byName([stored(null, undefined, 'draft')], { previewDrafts: true })).not.toHaveProperty('_packageId'); + }); +}); + +// ── 4. The draft list ─────────────────────────────────────────────────────── + +describe('[#28673] 4. the draft list serves the column\'s package', () => { + it('a package-less draft whose body carries `_packageId` is listed with none', async () => { + const items = await listed([stored(null, HOST, 'draft')], { previewDrafts: true }); + expect(items.map((i) => i.label)).toEqual(['draft row']); + expect(items[0]).not.toHaveProperty('_packageId'); + }); + + it('a draft whose body names another package is listed under the column\'s', async () => { + const items = await listed([stored(PKG_A, HOST, 'draft')], { previewDrafts: true }); + expect(items.map((i) => [i.label, i._packageId])).toEqual([['draft row', PKG_A]]); + }); + + it('control: an absent or agreeing stamp is listed as before', async () => { + expect((await listed([stored(PKG_A, undefined, 'draft')], { previewDrafts: true })).map((i) => i._packageId)).toEqual([PKG_A]); + expect((await listed([stored(PKG_A, PKG_A, 'draft')], { previewDrafts: true })).map((i) => i._packageId)).toEqual([PKG_A]); + expect(await listed([stored(null, undefined, 'draft')], { previewDrafts: true })).toEqual([ + expect.not.objectContaining({ _packageId: expect.anything() }), + ]); + }); +}); + +// ── 5. The list merge ─────────────────────────────────────────────────────── + +describe('[#28673] 5. the list merge serves a stored row under its own binding', () => { + const record = (stamp: string | undefined, packageId: string | undefined, standIn = false) => ({ + data: { name: NAME, label: 'row', ...(stamp === undefined ? {} : { _packageId: stamp }) }, + packageId, + stored: { type: TYPE }, + ...(standIn ? { standIn: true } : {}), + }); + + it('a package-less row whose body carries `_packageId` is served with none', () => { + const out = mergePackageAwareOverlay(TYPE, [], [record(HOST, undefined)]) as any[]; + expect(out).toHaveLength(1); + expect(out[0]).not.toHaveProperty('_packageId'); + }); + + it('a package-less row standing in for a package\'s slot is served as that package\'s, whatever its body says', () => { + const base = { name: NAME, label: 'packaged', _packageId: PKG_A }; + const out = mergePackageAwareOverlay(TYPE, [base], [record(HOST, undefined)]) as any[]; + expect(out.map((i) => [i.label, i._packageId])).toEqual([['row', PKG_A]]); + }); + + it('a row whose body names another package is served under its own', () => { + const out = mergePackageAwareOverlay(TYPE, [], [record(HOST, PKG_A)]) as any[]; + expect(out.map((i) => i._packageId)).toEqual([PKG_A]); + }); + + it('control: an absent or agreeing stamp is served as before, and the caller\'s body is not written', () => { + const rec = record(undefined, undefined); + const base = { name: NAME, label: 'packaged', _packageId: PKG_A }; + expect((mergePackageAwareOverlay(TYPE, [base], [rec]) as any[]).map((i) => i._packageId)).toEqual([PKG_A]); + expect(rec.data).not.toHaveProperty('_packageId'); + expect((mergePackageAwareOverlay(TYPE, [], [record(PKG_A, PKG_A)]) as any[]).map((i) => i._packageId)).toEqual([PKG_A]); + expect(mergePackageAwareOverlay(TYPE, [], [record(undefined, undefined)])).toEqual([ + expect.not.objectContaining({ _packageId: expect.anything() }), + ]); + }); +}); + +// ── 6. Hydration ──────────────────────────────────────────────────────────── + +/** A protocol whose registry records every `registerItem` call. */ +function hydrationHarness() { + const registered: Array<{ type: string; item: Record }> = []; + const registry = { + registerItem: (type: string, item: Record) => { registered.push({ type, item }); }, + unregisterItem: () => undefined, + getArtifactItem: (_type: string, _name: string, _pkg?: string) => undefined, + getItem: () => undefined, + listItems: () => [], + getObject: () => undefined, + getPackagedObjectOwner: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + }; + const engine: any = { + async find() { return []; }, + async findOne(table: string, opts?: { where?: Record }) { + assertEngineFindOnePredicate(table, opts); + return null; + }, + registry, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + const hydrate = (type: string, data: unknown, packageId: string | null) => + (protocol as any).hydrateOverlayIntoRegistry(type, data, { packageId, organizationId: null }) as Promise; + return { registered, hydrate }; +} + +describe('[#28673] 6. hydration registers a row under its column\'s package', () => { + it('a package-less row whose body carries `_packageId` registers with none', async () => { + const { registered, hydrate } = hydrationHarness(); + await hydrate(TYPE, { name: NAME, _packageId: HOST }, null); + expect(registered.map(({ item }) => item.name)).toEqual([NAME]); + expect(registered[0]!.item).not.toHaveProperty('_packageId'); + }); + + it('a row whose body names another package registers under the column\'s', async () => { + const { registered, hydrate } = hydrationHarness(); + await hydrate(TYPE, { name: NAME, _packageId: HOST }, PKG_A); + expect(registered.map(({ item }) => item._packageId)).toEqual([PKG_A]); + }); + + it('a package-less view container whose body carries `_packageId`: no item it expands to is registered as that package\'s', async () => { + const { registered, hydrate } = hydrationHarness(); + await hydrate('view', { + name: 'crm_note', + object: 'crm_note', + list: { type: 'grid', columns: ['name'] }, + _packageId: HOST, + }, null); + const expanded = registered.filter(({ item }) => item.name !== 'crm_note'); + expect(expanded.length).toBeGreaterThan(0); + for (const { item } of expanded) expect(item, String(item.name)).not.toHaveProperty('_packageId'); + }); + + it('control: an absent or agreeing stamp registers as before', async () => { + const { registered, hydrate } = hydrationHarness(); + await hydrate(TYPE, { name: NAME }, PKG_A); + await hydrate(TYPE, { name: NAME, _packageId: PKG_A }, PKG_A); + await hydrate(TYPE, { name: NAME }, null); + expect(registered.map(({ item }) => item._packageId)).toEqual([PKG_A, PKG_A, undefined]); + }); +}); From 192e943b5049d6f974caf1603f7cd23fd1f5862b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:50:38 +0000 Subject: [PATCH 2/4] fix(metadata-protocol)!: every metadata read states a stored row's package from its column One rule, `withRowBinding`, lifted from the list read's column-wins pass and used by the by-name active and draft reads, the by-name draft preview, the draft list, the list merge's stored rows and the registry hydration (with a view container row's expansions): a body `_packageId` is replaced by the column's package, or dropped on a package-less row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pjZq4w6ScHBPDy6t1 --- .../28673-every-read-row-column-binding.md | 20 ++++++ .../src/protocol-stored-overlay-reads.ts | 12 ++-- packages/metadata-protocol/src/protocol.ts | 63 +++++++++++++------ 3 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 .changeset/28673-every-read-row-column-binding.md diff --git a/.changeset/28673-every-read-row-column-binding.md b/.changeset/28673-every-read-row-column-binding.md new file mode 100644 index 00000000000..c2a06da89fb --- /dev/null +++ b/.changeset/28673-every-read-row-column-binding.md @@ -0,0 +1,20 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol)!: every metadata read states a stored row's package from its column, never from its body + +The list read serves a stored row's `_packageId` from its `package_id` column only (#28594); the other reads of a stored row still stamped the column's package only while the body carried none, so a body stamp the save door would strip was echoed back. Now one rule serves them all: the by-name active and strict draft reads, the by-name draft preview, the draft list, the list merge and the registry hydration (with the items a view container row expands to) state the column's package, replacing a body stamp that names another package and dropping one on a row bound to no package. + +Clause-②: no (narrowing) — a stored row whose body carries a `_packageId` other than its column's is served and registered under its column's package (or none) at every read, no longer under the body's. Nothing is widened. + +Metadata-protocol: none + + + +**BREAKING** read narrowing (`@objectstack/metadata-protocol`), shipped as `patch`. + +- **Before.** A by-name read, a draft read or preview, the draft list and the registry hydration served a stored row whose body carried `_packageId` with the body's value, whatever its column said. +- **Now.** Each serves it with its column's package, or with none when the row is bound to no package, as the list read does. +- **Unchanged.** A row whose body carries no stamp, or one equal to its column, is served exactly as before. +- **Who sees it.** Only a row written before the save door began stripping body stamps, or by a writer other than the save door. diff --git a/packages/metadata-protocol/src/protocol-stored-overlay-reads.ts b/packages/metadata-protocol/src/protocol-stored-overlay-reads.ts index 5a6ae53b908..fdb1d32e78f 100644 --- a/packages/metadata-protocol/src/protocol-stored-overlay-reads.ts +++ b/packages/metadata-protocol/src/protocol-stored-overlay-reads.ts @@ -45,7 +45,7 @@ import { type MetaOverlayCacheKey, } from './meta-overlay-cache.js'; import type { ObjectStackProtocolImplementation, StoredOverlayEntry } from './protocol.js'; -import { addressPackages } from './protocol.js'; +import { addressPackages, withRowBinding } from './protocol.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -171,13 +171,9 @@ export function storedOverlayEntries( // asks an item's owner of `_packageId` — the connector materializer // deciding whose root a file ref reads among them. A copy, so a cached // row's body is never written. - let data = parsed; - if (data && typeof data === 'object' && !Array.isArray(data) && (data as any)._packageId !== recPkg) { - const bound: Record = { ...(data as Record) }; - if (recPkg) bound._packageId = recPkg; - else delete bound._packageId; - data = bound; - } + // [#28673] The one rule every read of a stored row states its package + // by: {@link withRowBinding}. + const data = withRowBinding(parsed, recPkg) as any; // [ADR-0131 D6/D7] Every row is the environment's: the family // declares no organization column, and the reads that feed this // ({@link readActiveOverlayRows}) leave a legacy organization's diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b2b42b025ec..dfdd126303b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2651,17 +2651,32 @@ function stateTenantAuthorship(data: unknown): unknown { } /** - * [#23233] A stored row's body with its package binding stated as `_packageId`, - * the way `getMetaItems` stamps a row from its `package_id` column. The binding - * is read, never derived: a row bound to no package ({@link boundPackageOf}), - * or a body that already names a `_packageId`, is returned as it is. A copy, - * so the caller's body is never written. + * [#23233, #28594, #28673] A stored row's body with its package binding stated + * as `_packageId` from the row's `package_id` COLUMN, and only from it: the one + * rule every read of a stored row states its package by. A `_packageId` the + * body carries is not served: replaced by the column's package, or dropped when + * the row is bound to no package ({@link boundPackageOf}). The save door + * already strips a body's stamp ({@link stripDerivedProvenance}: the server + * states provenance, a caller never does), but a row at rest can still carry + * one, and a read that stamped the column only while the body named none + * echoed it back. The binding is read, never derived. A body that already + * states the column's binding is returned as it is; any other is a copy, so + * the caller's body (a cached row's among them) is never written. + * + * The readers: the list read ({@link storedOverlayEntries}), the list merge + * ({@link mergePackageAwareOverlay}), the draft list, the by-name active and + * draft reads, the by-name draft preview, and the registry hydration + * ({@link ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry}) with + * the items a view container row expands to. */ -function withRowBinding(data: unknown, packageId: unknown): unknown { +export function withRowBinding(data: unknown, packageId: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; const binding = boundPackageOf(packageId); - if (binding === undefined || !data || typeof data !== 'object' || Array.isArray(data)) return data; - if ((data as { _packageId?: unknown })._packageId !== undefined) return data; - return { ...(data as Record), _packageId: binding }; + if ((data as { _packageId?: unknown })._packageId === binding) return data; + const bound: Record = { ...(data as Record) }; + if (binding === undefined) delete bound._packageId; + else bound._packageId = binding; + return bound; } /** @@ -3126,7 +3141,13 @@ export function mergePackageAwareOverlay( ?? list.find((c) => c.pkg === undefined)?.item ?? list[0]?.item) : undefined; - push(slot, packageId, transform ? transform(body, prev) : body, stored, standIn); + // [#28673] A stored row enters under its own binding, the `package_id` + // column it is slotted by ({@link withRowBinding}), so a stamp its body + // carries is never served, here or where the row stands in for a + // package's slot below. A record that is not a stored row (a caller's + // item, slotted by its own `_packageId`) enters as it is. + const bound = stored ? withRowBinding(body, packageId) : body; + push(slot, packageId, transform ? transform(bound, prev) : bound, stored, standIn); } const out: unknown[] = []; @@ -10222,14 +10243,12 @@ export class ObjectStackProtocolImplementation implements // previews only its own package's entry, so two packages' // same-name drafts stay distinct. Draft rows win over active. const placeDraft = (record: any) => { - const data = this.convertStoredItem( + const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; + // [#28673] The draft's package is its column's ({@link withRowBinding}). + const data = withRowBinding(this.convertStoredItem( String(record.type ?? request.type), typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata, - ) as any; - const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; - if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { - (data as any)._packageId = recPkg; - } + ), recPkg) as any; // [#21804] The draft's place, as for the active rows: // a package's previewed slot is the draft the // by-name read's preview arm serves, in any row order. @@ -11295,7 +11314,7 @@ export class ObjectStackProtocolImplementation implements // overridden package's item, as its active row serves. if (servedDraft?.overrideOf !== undefined && servedDraft.row === draftRec) { previewed = { ...previewed, _packageId: servedDraft.overrideOf }; - } else if (recPkg && (previewed as any)._packageId === undefined) (previewed as any)._packageId = recPkg; + } else previewed = withRowBinding(previewed, recPkg); // [#28673] the column's package (previewed as any)._draft = true; } return { @@ -11374,8 +11393,9 @@ export class ObjectStackProtocolImplementation implements // that package's provenance, so the envelope below is the // overridden package's artifact's ({@link envelopePackageId}). item = { ...(item as Record), _packageId: served.overrideOf }; - } else if (recPkg && item && typeof item === 'object' && (item as any)._packageId === undefined) { - (item as any)._packageId = recPkg; + } else { + // [#28673] The column's package, and only the column's ({@link withRowBinding}). + item = withRowBinding(item, recPkg); } if (readState === 'draft') { draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes); @@ -19042,7 +19062,10 @@ export class ObjectStackProtocolImplementation implements await this.reconcileViewExpansions(type, { container: String((data as { name?: unknown }).name), binding: boundPackageOf(options.packageId), - expansion: this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true }), + // [#28673] The container under its column's binding + // ({@link withRowBinding}), so no item it expands to is placed + // in a package a stamp in its body names. + expansion: this.expandRuntimeViewContainer(type, withRowBinding(data, options.packageId), { ...options, tenantAuthored: true }), ...(options.reboundFrom === undefined ? {} : { reboundFrom: { binding: boundPackageOf(options.reboundFrom.packageId) } }), From 9ed89b48d8e87a8ee8638e92aba72e4522ffc2dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:57:33 +0000 Subject: [PATCH 3/4] test(metadata-protocol): drop an unused import from the row-binding pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pjZq4w6ScHBPDy6t1 --- .../metadata-protocol/src/protocol.row-binding-column.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.row-binding-column.test.ts b/packages/metadata-protocol/src/protocol.row-binding-column.test.ts index 12099386691..47e24a3f4d5 100644 --- a/packages/metadata-protocol/src/protocol.row-binding-column.test.ts +++ b/packages/metadata-protocol/src/protocol.row-binding-column.test.ts @@ -26,7 +26,7 @@ * back, served in a package's slot) is `protocol.scoped-list-fallback.test.ts`. */ import { describe, expect, it } from 'vitest'; -import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation, mergePackageAwareOverlay, withRowBinding } from './protocol.js'; const TYPE = 'dashboard'; From 47907244a8aff8759707d796dd46cff55562c056 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:38:27 +0000 Subject: [PATCH 4/4] chore(engine-double-contract): record the row-binding pins' engine doubles Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pjZq4w6ScHBPDy6t1 --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b86fa02b285..2919680b5d9 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1696,6 +1696,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.row-binding-column.test.ts", + "verb": "findOne", + "pinned": 2 + }, { "file": "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts", "verb": "delete",