From b776dcd193278ab0fdf87607bc95464cc8ffc976 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:48:57 +0000 Subject: [PATCH 1/9] fix(metadata-protocol): a save naming a package adopts the item's package-unbound row instead of minting a second row beside it A write that names a package, at either state, of an item whose only row at the address is the package-unbound one updates that row and stamps the binding (ADR-0070 TL;DR 1: the package-less state is a migration source). The publish of a package's draft reads its parent through the same write-address resolution, so it adopts the row too. Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 194 ++++++++++++++++++ .../src/sys-metadata-repository.ts | 21 +- 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 4e12caad209..8f5e4a1b520 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -686,3 +686,197 @@ describe('[#23297] CONTROL: one package and a package-less active row — the dr }); } }); + +/** + * [#23291, ADR-0070 TL;DR 1, ADR-0048] A save NAMING a package, of an item + * whose only row at the address is the package-unbound one, ADOPTS that row + * into the package: it updates the row and stamps the binding, and never + * mints a second row beside it. + * + * Before, the save inserted the package's row beside the unbound one, so the + * item held `[null, P]`, the unbound row first. The read naming no package + * (ADR-0048 §3.3, first match) then kept serving the unbound row, while the + * package-less save (#23165) wrote the package's row and the package-less + * delete removed it, the receipt saying the package-less row remained. A + * read and a save addressed by the same name reached different rows. + * + * Adopt, not refuse or re-route the read: ADR-0070 makes the package-less + * state "only a read-side rehydration detail and a migration source", and a + * save naming the package is that migration. The draft save already adopted + * an orphan package-less draft the same way (#11087); this is the same + * resolution for every write that names its package, the publish of a + * package's draft included. + * + * Each step's read is asserted against the address its save resolved. + */ +describe('[#23291] a save naming a package beside the item\'s package-unbound row adopts that row', () => { + it('the card\'s five steps: every read serves the row the save addressed', async () => { + const { protocol, rows, metadataWrites } = makeProtocol(); + const name = 's6_app'; + + // 1. A package-less save of an item with no binding: the unbound row. + const first = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + expect(first.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${first.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound'); + const unboundId = rows('app', name)[0]!.id; + + // 2. A save naming the package: the same row, now bound. No insert. + const insertsBefore = metadataWrites().filter((w) => w.op === 'insert').length; + const named = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + expect(named.message).toContain(`package=${WRITABLE}`); + expect(rows('app', name).map((r) => [r.id, r.package_id ?? null])).toEqual([[unboundId, WRITABLE]]); + expect(metadataWrites().filter((w) => w.op === 'insert' && w.table === 'sys_metadata')).toHaveLength(insertsBefore); + + // 3. Both reads serve it, with one version token: one address. + const unnamedRead = await protocol.getMetaItem({ type: 'app', name }); + const namedRead = await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE }); + expect(unnamedRead.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); + expect(namedRead.item).toMatchObject({ label: 'pkg' }); + expect(unnamedRead.version).toEqual(expect.any(String)); + expect(namedRead.version).toBe(unnamedRead.version); + + // 4. A package-less save resolves that package (#23165), and the read + // naming no package, the read naming it and the list all serve it. + const third = await protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'third' }), parentVersion: unnamedRead.version, + }); + expect(third.message).toBe(`Saved app '${name}' (env-wide, package=${WRITABLE}, state=active) [seq=${third.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('third'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('third'); + const listed = (await protocol.getMetaItems({ type: 'app' })).items.filter((i: any) => i?.name === name); + expect(listed.map((i: any) => i.label)).toEqual(['third']); + + // 5. A package-less delete removes that row, and the item is gone. + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + expect(receipt.message).toBe(`Deleted app '${name}' (package=${WRITABLE}) — it no longer exists. [seq=${receipt.seq}]`); + expect(rows('app', name)).toEqual([]); + const after = await protocol.getMetaItem({ type: 'app', name }).catch((e: unknown) => e); + expect(after?.item ?? null).toBeNull(); + }); + + it('the read naming the package serves the token of the row the save adopts, and read → save with it is accepted', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_token_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + + const { version } = await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE }); + // The unbound row's token: the head the save naming the package writes. + expect(version).toBe((await protocol.getMetaItem({ type: 'app', name })).version); + expect(version).toEqual(expect.any(String)); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE, parentVersion: version }); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + + // A create assertion meets the row that exists at the address. + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'again' }), packageId: OTHER_WRITABLE, parentVersion: version, + }))); + expect(envelope).toMatchObject({ status: 409, code: 'METADATA_CONFLICT' }); + }); + + it('the adopted row keeps the lock family it stores', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_locked_app'; + const stamped = { _lock: 'no-delete', _lockReason: 'Stamped by the platform.', _lockSource: 'package' }; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, stamped), packageId: null, source: 'migrate-stored', force: true }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: WRITABLE }); + + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'Edited', ...stamped }); + }); + + it('a draft saved naming the package, then published, adopts the unbound active row', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_publish_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'drafted' }), packageId: WRITABLE, mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + await protocol.publishMetaItem({ type: 'app', name, packageId: WRITABLE }); + + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(rows('app', name, 'draft')).toEqual([]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('drafted'); + }); + + it('a draft naming the package adopts the item\'s package-unbound draft', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_draft_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound draft' }), mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([null]); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg draft' }), packageId: WRITABLE, mode: 'draft' }); + + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('pkg draft'); + }); + + it('edge: another package already holds a row — the unbound row is adopted, and the item is then a two-package item', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 's6_shared_app'; + // Rows stored before this fix: the package-unbound row beside another + // package's (no door mints that pair now: the save naming the other + // package would have adopted the unbound row). + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound' })), checksum: 'sha-unbound' }); + seedRow({ type: 'app', name, package_id: OTHER_WRITABLE, metadata: JSON.stringify(app(name, { label: 'other' })), checksum: 'sha-other' }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + + expect(bodiesByBinding(rows('app', name))).toEqual({ + [WRITABLE]: app(name, { label: 'pkg' }), + [OTHER_WRITABLE]: app(name, { label: 'other' }), + }); + // Each read naming a package serves its own row; no package-unbound row is left to serve. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('pkg'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('other'); + expect(['pkg', 'other']).toContain((await protocol.getMetaItem({ type: 'app', name })).item.label); + // The package-less save and delete are refused as for any two-package item (#23165). + for (const run of [ + () => protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'x' }) }), + () => protocol.deleteMetaItem({ type: 'app', name }), + ]) { + expect(resolveThrownHttpError(await refusalOf(run))).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + } + expect(rows('app', name)).toHaveLength(2); + }); + + it('edge: a save naming a second package after the adoption writes its own row, never the first package\'s', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_second_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: OTHER_WRITABLE }); + + expect(bodiesByBinding(rows('app', name))).toEqual({ + [WRITABLE]: app(name, { label: 'pkg' }), + [OTHER_WRITABLE]: app(name, { label: 'other' }), + }); + }); + + it('CONTROL: a package\'s own row is written in place; a package-unbound row stored beside it stays as it is', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 's6_residue_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: WRITABLE }); + + const bodies = bodiesByBinding(rows('app', name)); + expect(bodies[WRITABLE]).toMatchObject({ label: 'Edited' }); + expect(bodies.null).toEqual(app(name, { active: false })); + }); + + it('CONTROL: a delete naming the package removes no package-unbound row', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_delete_named_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); + + await protocol.deleteMetaItem({ type: 'app', name, packageId: WRITABLE }); + + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + }); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index b4d901f7ef0..16d9fa624aa 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -864,7 +864,19 @@ export class SysMetadataRepository implements MetadataRepository { // misses it — creating a second draft row would fork the pending change. // That package-less row is the one the write updates; `put`'s stamp // (`existingPkg ?? targetPackageId`) then adopts it into the package. - if (!row && state === 'draft' && packageId == null && targetPackageId !== null) { + // + // [#23291, ADR-0070 TL;DR 1] …and the same for a write that NAMES its + // package, at either state: the package-unbound row is "only a read-side + // rehydration detail and a migration source", and a write naming the + // package is that migration. Inserting the package's row beside it left + // the item `[null, P]`: the read naming no package (ADR-0048 §3.3, first + // match) kept serving the unbound row while the package-less save and + // delete (#23165) addressed the package's, so one name reached two rows. + // Adopted, the item has one row and every door addresses it. Only when + // the package has no row of its own here: a package-unbound row stored + // beside one (rows minted before this) is left as it is, and a row bound + // to another package is never found, as above. + if (!row && targetPackageId !== null) { row = await this.engine.findOne('sys_metadata', { where: this.whereFor(ref, state, null, member), context, @@ -1382,7 +1394,12 @@ export class SysMetadataRepository implements MetadataRepository { // Read the active row through the SAME package scope we will write, so the // optimistic-lock `parentVersion` matches the exact row `put` upserts. // (Package-less drafts → packageId null → identical to the prior behaviour.) - const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId, member: opts.member }); + // + // [#23291] Through {@link headAt}, the put's own write-address resolution, + // never {@link get} at the key: a draft bound to a package whose item has + // only the package-unbound active row ADOPTS that row, so the parent is + // that row's version. + const currentActive = (await this.headAt(ref, { state: 'active', packageId: draftPackageId, member: opts.member })).head; const activeBody = opts.deriveActiveBody ? opts.deriveActiveBody(draft.body) : draft.body; const result = await this.put(ref, activeBody, { parentVersion: currentActive?.hash ?? null, From 5e94562640dfc025637630518084a0f511c60773 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:56:04 +0000 Subject: [PATCH 2/9] fix(metadata-protocol): the read naming no package serves the row the package-less save writes, not the first row of any package Option (c) for the S6 shape: an item bound to exactly one package, with a package-unbound row stored beside the package's row, is read as the read naming that package reads it (ADR-0048 prefer-local), at the address packagelessAddress resolves for the save, the delete and the version token. The package-unbound row is kept, as ADR-0070 TL;DR 1 keeps it and as it stands in for every package with no row of its own. Reverts the adopt attempt of the previous commit, which removed that stand-in. Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 235 ++++++++---------- packages/metadata-protocol/src/protocol.ts | 29 ++- .../src/sys-metadata-repository.ts | 21 +- 3 files changed, 130 insertions(+), 155 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 8f5e4a1b520..a8bf37cef8c 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -41,6 +41,10 @@ * - the draft path: a package-less draft save still inherits the package * (its own suite, `sys-metadata-repository.draft-package-inherit.test.ts`, * holds the rest). + * - [#23291] the read naming no package serves the row that save writes, + * with a package-unbound row stored beside the package's: the card's five + * steps, both storage orders, the draft reads, the stand-in for another + * package, and an item another package holds too. * * Refusals are asserted as the envelope an HTTP boundary answers with * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. @@ -688,30 +692,28 @@ describe('[#23297] CONTROL: one package and a package-less active row — the dr }); /** - * [#23291, ADR-0070 TL;DR 1, ADR-0048] A save NAMING a package, of an item - * whose only row at the address is the package-unbound one, ADOPTS that row - * into the package: it updates the row and stamps the binding, and never - * mints a second row beside it. + * [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The read that names no package + * serves the row a save naming none writes. * - * Before, the save inserted the package's row beside the unbound one, so the - * item held `[null, P]`, the unbound row first. The read naming no package - * (ADR-0048 §3.3, first match) then kept serving the unbound row, while the - * package-less save (#23165) wrote the package's row and the package-less - * delete removed it, the receipt saying the package-less row remained. A - * read and a save addressed by the same name reached different rows. + * A save naming a package, of an item whose only row is the package-unbound + * one, stores the package's row beside it, the unbound row first. The read + * naming no package took the first row of any package, so it kept serving the + * unbound body while the package-less save wrote the package's row (#23165), + * the package-less delete removed it, the list served it, and the read's own + * `version` named it: one name, two rows. * - * Adopt, not refuse or re-route the read: ADR-0070 makes the package-less - * state "only a read-side rehydration detail and a migration source", and a - * save naming the package is that migration. The draft save already adopted - * an orphan package-less draft the same way (#11087); this is the same - * resolution for every write that names its package, the publish of a - * package's draft included. + * The read now resolves the address the save and the delete resolve + * (`packagelessAddress`): for an item bound to exactly one package it serves + * what the read naming that package serves. The package-unbound row is kept: + * ADR-0070 leaves it as a read-side detail and a migration source, and it still + * stands in for every package with no row of its own (ADR-0048 prefer-local), + * which `protocol.org-scoped-write-refused.test.ts` pins for view containers. * * Each step's read is asserted against the address its save resolved. */ -describe('[#23291] a save naming a package beside the item\'s package-unbound row adopts that row', () => { +describe('[#23291] the read naming no package serves the row the save naming none writes', () => { it('the card\'s five steps: every read serves the row the save addressed', async () => { - const { protocol, rows, metadataWrites } = makeProtocol(); + const { protocol, rows } = makeProtocol(); const name = 's6_app'; // 1. A package-less save of an item with no binding: the unbound row. @@ -719,164 +721,129 @@ describe('[#23291] a save naming a package beside the item\'s package-unbound ro expect(first.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${first.seq}]`); expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound'); - const unboundId = rows('app', name)[0]!.id; - // 2. A save naming the package: the same row, now bound. No insert. - const insertsBefore = metadataWrites().filter((w) => w.op === 'insert').length; + // 2. A save naming the package writes the package's row; the unbound row stays, first. const named = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); expect(named.message).toContain(`package=${WRITABLE}`); - expect(rows('app', name).map((r) => [r.id, r.package_id ?? null])).toEqual([[unboundId, WRITABLE]]); - expect(metadataWrites().filter((w) => w.op === 'insert' && w.table === 'sys_metadata')).toHaveLength(insertsBefore); + expect(rows('app', name).map((r) => [r.package_id ?? null, JSON.parse(r.metadata).label])) + .toEqual([[null, 'unbound'], [WRITABLE, 'pkg']]); - // 3. Both reads serve it, with one version token: one address. + // 3. The read naming no package serves the package's row, with the + // content and the version token of the read naming the package. const unnamedRead = await protocol.getMetaItem({ type: 'app', name }); const namedRead = await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE }); expect(unnamedRead.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); expect(namedRead.item).toMatchObject({ label: 'pkg' }); expect(unnamedRead.version).toEqual(expect.any(String)); - expect(namedRead.version).toBe(unnamedRead.version); + expect(unnamedRead.version).toBe(namedRead.version); - // 4. A package-less save resolves that package (#23165), and the read - // naming no package, the read naming it and the list all serve it. + // 4. A package-less save, pinned to that token, writes the package's + // row (#23165), and the reads and the list serve what it wrote. const third = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'third' }), parentVersion: unnamedRead.version, }); expect(third.message).toBe(`Saved app '${name}' (env-wide, package=${WRITABLE}, state=active) [seq=${third.seq}]`); - expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))).toEqual({ null: app(name, { label: 'unbound' }), [WRITABLE]: app(name, { label: 'third' }) }); expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('third'); expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('third'); const listed = (await protocol.getMetaItems({ type: 'app' })).items.filter((i: any) => i?.name === name); expect(listed.map((i: any) => i.label)).toEqual(['third']); - // 5. A package-less delete removes that row, and the item is gone. + // 5. A package-less delete removes the package's row and says the + // package-less row remains; that row is now the address of the + // read and of the next package-less save alike. const receipt = await protocol.deleteMetaItem({ type: 'app', name }); - expect(receipt.message).toBe(`Deleted app '${name}' (package=${WRITABLE}) — it no longer exists. [seq=${receipt.seq}]`); + expect(receipt.message).toBe( + `Deleted app '${name}' (package=${WRITABLE}), and it still exists: its package-less row remains. [seq=${receipt.seq}]`, + ); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound'); + const after = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound again' }) }); + expect(after.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${after.seq}]`); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('unbound again'); + // And a second package-less delete removes it: the item is gone. + const last = await protocol.deleteMetaItem({ type: 'app', name }); + expect(last.message).toBe(`Deleted app '${name}' — it no longer exists. [seq=${last.seq}]`); expect(rows('app', name)).toEqual([]); - const after = await protocol.getMetaItem({ type: 'app', name }).catch((e: unknown) => e); - expect(after?.item ?? null).toBeNull(); }); - it('the read naming the package serves the token of the row the save adopts, and read → save with it is accepted', async () => { - const { protocol, rows } = makeProtocol(); - const name = 's6_token_app'; - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + for (const unboundFirst of [true, false]) { + it(`whichever order the store holds the two rows in (package-unbound row ${unboundFirst ? 'first' : 'second'})`, async () => { + const { protocol, seedRow } = makeProtocol(); + const name = 's6_order_app'; + const unbound = () => seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound' })), checksum: 'sha-unbound' }); + if (unboundFirst) unbound(); + seedRow({ type: 'app', name, package_id: WRITABLE, metadata: JSON.stringify(app(name, { label: 'pkg' })), checksum: 'sha-pkg' }); + if (!unboundFirst) unbound(); - const { version } = await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE }); - // The unbound row's token: the head the save naming the package writes. - expect(version).toBe((await protocol.getMetaItem({ type: 'app', name })).version); - expect(version).toEqual(expect.any(String)); - - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE, parentVersion: version }); - expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + const read = await protocol.getMetaItem({ type: 'app', name }); - // A create assertion meets the row that exists at the address. - const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ - type: 'app', name, item: app(name, { label: 'again' }), packageId: OTHER_WRITABLE, parentVersion: version, - }))); - expect(envelope).toMatchObject({ status: 409, code: 'METADATA_CONFLICT' }); - }); - - it('the adopted row keeps the lock family it stores', async () => { - const { protocol, rows } = makeProtocol(); - const name = 's6_locked_app'; - const stamped = { _lock: 'no-delete', _lockReason: 'Stamped by the platform.', _lockSource: 'package' }; - await protocol.saveMetaItem({ type: 'app', name, item: app(name, stamped), packageId: null, source: 'migrate-stored', force: true }); - - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: WRITABLE }); - - expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); - expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'Edited', ...stamped }); - }); + expect(read.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); + expect(read.version).toBe((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).version); + }); + } - it('a draft saved naming the package, then published, adopts the unbound active row', async () => { - const { protocol, rows } = makeProtocol(); - const name = 's6_publish_app'; + it('the package-unbound row still stands in for a package with no row of its own (ADR-0048 prefer-local)', async () => { + const { protocol } = makeProtocol(); + const name = 's6_standin_app'; await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'drafted' }), packageId: WRITABLE, mode: 'draft' }); - expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); - await protocol.publishMetaItem({ type: 'app', name, packageId: WRITABLE }); - - expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); - expect(rows('app', name, 'draft')).toEqual([]); - expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('drafted'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('unbound'); }); - it('a draft naming the package adopts the item\'s package-unbound draft', async () => { - const { protocol, rows } = makeProtocol(); + it('the draft reads serve the draft a package-less draft save writes: the package\'s', async () => { + const { protocol, rows, seedRow } = makeProtocol(); const name = 's6_draft_app'; - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound draft' }), mode: 'draft' }); - expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([null]); - - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg draft' }), packageId: WRITABLE, mode: 'draft' }); - - expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); - expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('pkg draft'); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + // Drafts stored before this: the package-unbound one first, the package's beside it. + seedRow({ type: 'app', name, state: 'draft', package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound draft' })), checksum: 'sha-ud' }); + seedRow({ type: 'app', name, state: 'draft', package_id: WRITABLE, metadata: JSON.stringify(app(name, { label: 'pkg draft' })), checksum: 'sha-pd' }); + + const strict = await protocol.getMetaItem({ type: 'app', name, state: 'draft' }); + expect(strict.item.label).toBe('pkg draft'); + expect(strict.version).toBe((await protocol.getMetaItem({ type: 'app', name, state: 'draft', packageId: WRITABLE })).version); + expect((await protocol.getMetaItem({ type: 'app', name, previewDrafts: true })).item).toMatchObject({ label: 'pkg draft', _draft: true }); + + // The package-less draft save, pinned to that token, writes that draft + // (it inherits the package of the active rows, #11087). + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg draft 2' }), mode: 'draft', parentVersion: strict.version }); + expect(bodiesByBinding(rows('app', name, 'draft'))).toEqual({ + null: app(name, { label: 'unbound draft' }), + [WRITABLE]: app(name, { label: 'pkg draft 2' }), + }); + expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('pkg draft 2'); }); - it('edge: another package already holds a row — the unbound row is adopted, and the item is then a two-package item', async () => { - const { protocol, rows, seedRow } = makeProtocol(); + it('edge: another package holds a row too — the read is ADR-0048 §3.3\'s best effort, and the package-less save and delete are refused', async () => { + const { protocol, rows } = makeProtocol(); const name = 's6_shared_app'; - // Rows stored before this fix: the package-unbound row beside another - // package's (no door mints that pair now: the save naming the other - // package would have adopted the unbound row). - seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound' })), checksum: 'sha-unbound' }); - seedRow({ type: 'app', name, package_id: OTHER_WRITABLE, metadata: JSON.stringify(app(name, { label: 'other' })), checksum: 'sha-other' }); - + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: OTHER_WRITABLE }); + const before = bodiesByBinding(rows('app', name)); + expect(Object.keys(before).sort()).toEqual([WRITABLE, OTHER_WRITABLE, 'null'].sort()); - expect(bodiesByBinding(rows('app', name))).toEqual({ - [WRITABLE]: app(name, { label: 'pkg' }), - [OTHER_WRITABLE]: app(name, { label: 'other' }), - }); - // Each read naming a package serves its own row; no package-unbound row is left to serve. - expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('pkg'); - expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('other'); - expect(['pkg', 'other']).toContain((await protocol.getMetaItem({ type: 'app', name })).item.label); - // The package-less save and delete are refused as for any two-package item (#23165). + // No package-less write address exists, so neither write is guessed. for (const run of [ () => protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'x' }) }), () => protocol.deleteMetaItem({ type: 'app', name }), ]) { expect(resolveThrownHttpError(await refusalOf(run))).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); } - expect(rows('app', name)).toHaveLength(2); - }); - - it('edge: a save naming a second package after the adoption writes its own row, never the first package\'s', async () => { - const { protocol, rows } = makeProtocol(); - const name = 's6_second_app'; - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); - - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: OTHER_WRITABLE }); - - expect(bodiesByBinding(rows('app', name))).toEqual({ - [WRITABLE]: app(name, { label: 'pkg' }), - [OTHER_WRITABLE]: app(name, { label: 'other' }), - }); - }); - - it('CONTROL: a package\'s own row is written in place; a package-unbound row stored beside it stays as it is', async () => { - const { protocol, rows, seedRow } = makeProtocol(); - const name = 's6_residue_app'; - await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); - seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); - - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: WRITABLE }); - - const bodies = bodiesByBinding(rows('app', name)); - expect(bodies[WRITABLE]).toMatchObject({ label: 'Edited' }); - expect(bodies.null).toEqual(app(name, { active: false })); - }); - - it('CONTROL: a delete naming the package removes no package-unbound row', async () => { - const { protocol, rows } = makeProtocol(); - const name = 's6_delete_named_app'; - await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); - - await protocol.deleteMetaItem({ type: 'app', name, packageId: WRITABLE }); + expect(bodiesByBinding(rows('app', name))).toEqual(before); + // Each read naming a package serves its own row; the read naming none serves a stored row. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item.label).toBe('pkg'); + expect((await protocol.getMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE })).item.label).toBe('other'); + expect(['unbound', 'pkg', 'other']).toContain((await protocol.getMetaItem({ type: 'app', name })).item.label); - expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + // A delete naming one package leaves a one-package item: the reads and + // the package-less doors address that package's row again. + await protocol.deleteMetaItem({ type: 'app', name, packageId: OTHER_WRITABLE }); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pkg'); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg 2' }) }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pkg 2'); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 51cef72d056..4ffc6d7886d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10689,6 +10689,31 @@ export class ObjectStackProtocolImplementation implements // is answered as a draft, as above. const storedRowDeclined = readState === 'active' && this.declinesStoredRow(request.type, request.name); + // [#23291, ADR-0048 §3.3, ADR-0070 TL;DR 1] The package whose stored + // content a read that names no package serves: the address a save or + // a delete naming none writes ({@link packagelessAddress}), read from + // the item's ACTIVE rows at both states, as the draft save inherits + // its package from them (#11087). For an item bound to exactly one + // package, the read then serves what the read naming that package + // serves (its own row, else the package-less one); otherwise any row, + // the best-effort first match ADR-0048 §3.3 states. + // + // Before, it took the first row of any package. A save naming the + // package beside the item's package-unbound row mints the package's + // row and leaves the unbound one stored first, so this read kept + // serving the unbound body while the package-less save wrote the + // package's row, the package-less delete removed it, the list served + // it, and this read's own `version` named it (#23165). The package-less + // row is not converted or removed: ADR-0070 keeps it as a read-side + // detail and a migration source, and it still stands in for every + // package with no row of its own (ADR-0048 prefer-local). + // + // Content only: the lock is selected from the item's rows at the + // address the `_lock` gate judges a package-less save at, below. + const contentPackageId = request.packageId + || (await this.packagelessAddress(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member)).packageId + || undefined; + // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else // fall back to the active read below. This differs from the strict @@ -10704,7 +10729,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: 'draft', - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; if (draftRec) { @@ -10750,7 +10775,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: readState, - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; storedRowServed = record !== undefined && record !== null; diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 16d9fa624aa..b4d901f7ef0 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -864,19 +864,7 @@ export class SysMetadataRepository implements MetadataRepository { // misses it — creating a second draft row would fork the pending change. // That package-less row is the one the write updates; `put`'s stamp // (`existingPkg ?? targetPackageId`) then adopts it into the package. - // - // [#23291, ADR-0070 TL;DR 1] …and the same for a write that NAMES its - // package, at either state: the package-unbound row is "only a read-side - // rehydration detail and a migration source", and a write naming the - // package is that migration. Inserting the package's row beside it left - // the item `[null, P]`: the read naming no package (ADR-0048 §3.3, first - // match) kept serving the unbound row while the package-less save and - // delete (#23165) addressed the package's, so one name reached two rows. - // Adopted, the item has one row and every door addresses it. Only when - // the package has no row of its own here: a package-unbound row stored - // beside one (rows minted before this) is left as it is, and a row bound - // to another package is never found, as above. - if (!row && targetPackageId !== null) { + if (!row && state === 'draft' && packageId == null && targetPackageId !== null) { row = await this.engine.findOne('sys_metadata', { where: this.whereFor(ref, state, null, member), context, @@ -1394,12 +1382,7 @@ export class SysMetadataRepository implements MetadataRepository { // Read the active row through the SAME package scope we will write, so the // optimistic-lock `parentVersion` matches the exact row `put` upserts. // (Package-less drafts → packageId null → identical to the prior behaviour.) - // - // [#23291] Through {@link headAt}, the put's own write-address resolution, - // never {@link get} at the key: a draft bound to a package whose item has - // only the package-unbound active row ADOPTS that row, so the parent is - // that row's version. - const currentActive = (await this.headAt(ref, { state: 'active', packageId: draftPackageId, member: opts.member })).head; + const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId, member: opts.member }); const activeBody = opts.deriveActiveBody ? opts.deriveActiveBody(draft.body) : draft.body; const result = await this.put(ref, activeBody, { parentVersion: currentActive?.hash ?? null, From 890bf1d349dc3e42385449239a092e845283703f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:01:28 +0000 Subject: [PATCH 3/9] changeset: the read naming no package serves the package-less save's row (patch) Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../23291-packageless-read-serves-save-address.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/23291-packageless-read-serves-save-address.md diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/23291-packageless-read-serves-save-address.md new file mode 100644 index 00000000000..52085d1ba43 --- /dev/null +++ b/.changeset/23291-packageless-read-serves-save-address.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a metadata read that names no package serves the row a save that names no package writes, when a package-unbound row of the item is stored beside its one package's row (ADR-0048, ADR-0070) + +Clause-②: no + +- **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body, while the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. +- **What it does now.** For an item whose stored rows are bound to exactly one package, the read naming no package serves what the read naming that package serves: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve, so all of them name one row. The draft reads (`?state=draft` and the draft preview) take the same address, as the package-less draft save inherits that package. An item bound to two or more packages is read as before (the first stored row, ADR-0048 §3.3's best effort); a save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. +- **What does not change.** No stored row is rewritten or removed, and no request is refused that was accepted before. The package-unbound row still stands in for every package that has no row of its own (a read naming such a package serves it), and once the package's row is deleted it is what the read and the next package-less save address again. From b463e4beb49f0428df527b573dd90ebf69cf83a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:36:54 +0000 Subject: [PATCH 4/9] fix(metadata-protocol): the layered read naming no package takes its overlay from the address the plain read serves getMetaItemLayered (`/layers`, `?layers=`, `/published` on both hosts) selects its overlay content row at packagelessReadAddress, the helper getMetaItem now shares, so `effective` is what the plain read returns. The lock stays at the request's address. Pins: the layered read in both storage orders, its stand-in control, and the draft corner (active rows bound to P, the only draft bound to Q: the strict read naming none answers 404 NO_DRAFT, the preview serves P's active row). Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 48 ++++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 26 ++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index a8bf37cef8c..82bda840991 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -43,8 +43,9 @@ * holds the rest). * - [#23291] the read naming no package serves the row that save writes, * with a package-unbound row stored beside the package's: the card's five - * steps, both storage orders, the draft reads, the stand-in for another - * package, and an item another package holds too. + * steps, both storage orders (the plain and the layered read), the draft + * reads and their corner, the stand-in for another package, and an item + * another package holds too. * * Refusals are asserted as the envelope an HTTP boundary answers with * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. @@ -736,6 +737,9 @@ describe('[#23291] the read naming no package serves the row the save naming non expect(namedRead.item).toMatchObject({ label: 'pkg' }); expect(unnamedRead.version).toEqual(expect.any(String)); expect(unnamedRead.version).toBe(namedRead.version); + const layered = await protocol.getMetaItemLayered({ type: 'app', name }); + expect(layered.overlay).toMatchObject({ label: 'pkg' }); + expect(layered.effective).toMatchObject({ label: 'pkg' }); // 4. A package-less save, pinned to that token, writes the package's // row (#23165), and the reads and the list serve what it wrote. @@ -780,9 +784,49 @@ describe('[#23291] the read naming no package serves the row the save naming non expect(read.item).toMatchObject({ label: 'pkg', _packageId: WRITABLE }); expect(read.version).toBe((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).version); + + // The layered read (`/layers`, `?layers=`, `/published`): its + // overlay is the package's row, and `effective` is what the plain + // read returns. + const layered = await protocol.getMetaItemLayered({ type: 'app', name }); + expect(layered.overlay).toMatchObject({ label: 'pkg' }); + expect(layered.effective).toMatchObject({ name, label: 'pkg' }); + expect((layered.effective as any).label).toBe(read.item.label); + expect(layered.overlay).toEqual((await protocol.getMetaItemLayered({ type: 'app', name, packageId: WRITABLE })).overlay); }); } + it('the layered read naming a package with no row of its own still reports the package-unbound row (the stand-in)', async () => { + const { protocol } = makeProtocol(); + const name = 's6_layered_standin_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }) }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + + const layered = await protocol.getMetaItemLayered({ type: 'app', name, packageId: OTHER_WRITABLE }); + + expect(layered.overlay).toMatchObject({ label: 'unbound' }); + expect(layered.effective).toMatchObject({ label: 'unbound' }); + }); + + it('the corner: active rows bound to the package, the only draft bound to ANOTHER package — the strict draft read naming none answers 404 NO_DRAFT, the preview serves the active row', async () => { + const { protocol, rows } = makeProtocol(); + const name = 's6_draft_corner_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pkg' }), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other draft' }), packageId: OTHER_WRITABLE, mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([OTHER_WRITABLE]); + + // The address a package-less draft save writes is the package's draft, + // and there is none: the absence answer, as its version token was. + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.getMetaItem({ type: 'app', name, state: 'draft' }))); + expect(envelope).toMatchObject({ status: 404, code: 'NO_DRAFT' }); + // That draft is read by naming its package. + expect((await protocol.getMetaItem({ type: 'app', name, state: 'draft', packageId: OTHER_WRITABLE })).item.label).toBe('other draft'); + // The preview naming none falls back to the active row. + const preview = await protocol.getMetaItem({ type: 'app', name, previewDrafts: true }); + expect(preview.item.label).toBe('pkg'); + expect(preview.item._draft).toBeUndefined(); + }); + it('the package-unbound row still stands in for a package with no row of its own (ADR-0048 prefer-local)', async () => { const { protocol } = makeProtocol(); const name = 's6_standin_app'; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 47b6f88b98c..fd0049cb55b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10710,9 +10710,7 @@ export class ObjectStackProtocolImplementation implements // // Content only: the lock is selected from the item's rows at the // address the `_lock` gate judges a package-less save at, below. - const contentPackageId = request.packageId - || (await this.packagelessAddress(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member)).packageId - || undefined; + const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name, member); // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else @@ -11327,11 +11325,18 @@ export class ObjectStackProtocolImplementation implements try { // ADR-0048 prefer-local. [#21716] The ONE served-row resolution // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. + // + // [#23291] Naming no package, at the address {@link getMetaItem} + // serves its content from ({@link packagelessReadAddress}), so + // `effective` is what that read returns and `/published` serves + // the same document. Content only: the lock stays at the + // request's address, as the `_lock` gate judges it. + const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name); const served = await this.findServedOverlayRow({ type: request.type, name: request.name, state: 'active', - ...(request.packageId ? { packageId: request.packageId } : {}), + ...(contentPackageId ? { packageId: contentPackageId } : {}), }); overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, @@ -18801,6 +18806,19 @@ export class ObjectStackProtocolImplementation implements return stored === null ? null : this.receiptVersion(stored); } + /** + * [#23291, ADR-0048 §3.3] The package a by-name read that names no package + * takes its stored CONTENT from: the item's own package when its active + * rows are bound to exactly one ({@link packagelessAddress}, the address + * the save, the delete and the read's `version` resolve), else + * `undefined`, any row (the best-effort first match). {@link getMetaItem} + * and {@link getMetaItemLayered} both ask it, so the plain read, the + * layered read's `effective` and `/published` serve one row. + */ + private async packagelessReadAddress(type: string, name: string, member?: string): Promise { + return (await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member)).packageId ?? undefined; + } + /** * [#23165, ADR-0048] THE package-less write address: which row of an item a * write that names no package addresses, read from the item's stored rows From 5eb966c009de7ff0f27597ee857cd038c6f72938 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:57:36 +0000 Subject: [PATCH 5/9] changeset: name the layered, published and draft reads, and the draft read's 200 to 404 corner Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../23291-packageless-read-serves-save-address.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/23291-packageless-read-serves-save-address.md index 52085d1ba43..a9aa8db49c6 100644 --- a/.changeset/23291-packageless-read-serves-save-address.md +++ b/.changeset/23291-packageless-read-serves-save-address.md @@ -6,6 +6,13 @@ fix(metadata-protocol): a metadata read that names no package serves the row a s Clause-②: no -- **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body, while the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. -- **What it does now.** For an item whose stored rows are bound to exactly one package, the read naming no package serves what the read naming that package serves: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve, so all of them name one row. The draft reads (`?state=draft` and the draft preview) take the same address, as the package-less draft save inherits that package. An item bound to two or more packages is read as before (the first stored row, ADR-0048 §3.3's best effort); a save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. -- **What does not change.** No stored row is rewritten or removed, and no request is refused that was accepted before. The package-unbound row still stands in for every package that has no row of its own (a read naming such a package serves it), and once the package's row is deleted it is what the read and the next package-less save address again. +- **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, with the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body. Meanwhile the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. A save pinned to the served token overwrote the package's row with an edit of the unbound body. +- **What it does now.** For an item whose stored active rows are bound to exactly one package, a read that names no package takes its stored content from what the read naming that package takes it from: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve. The doors whose served row changes: + - the item read, `GET /api/v1/meta/TYPE/NAME`; + - the layered read, `GET …/layers` and the deprecated `?layers=` (its `overlay` and `effective`); + - the published read, `GET …/published`, on the REST server and on the dispatcher; + - the draft reads, `?state=draft` and the draft preview, whose address comes from the same active rows, because the package-less draft save inherits that package. + + An item bound to two or more packages is read as before: the first stored row, ADR-0048 §3.3's best effort. A save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. The lock reported by these reads is selected as before. +- **One answer changes from 200 to 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. +- **What does not change.** No stored row is rewritten or removed. No key, export or schema moves. No write is refused that was accepted before. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. From e39d82deb3967107a67254da67653b14a3cedd8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:03:21 +0000 Subject: [PATCH 6/9] fix(metadata-protocol): resolve the layered read's content address outside its catch, so an outage keeps the driver's error as cause Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fd0049cb55b..ca9b0fe0365 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11322,16 +11322,17 @@ export class ObjectStackProtocolImplementation implements // strictest lock among the item's stored rows in scope, never only the // row this layer reports. let overlayLockLayer: unknown; + // [#23291] Naming no package, the overlay comes from the address + // {@link getMetaItem} serves its content from + // ({@link packagelessReadAddress}), so `effective` is what that read + // returns and `/published` serves the same document. Content only: the + // lock stays at the request's address, as the `_lock` gate judges it. + // Asked outside the `try` below: its own failed read already answers + // the #5532 envelope, with the driver's error as `cause`. + const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name); try { // ADR-0048 prefer-local. [#21716] The ONE served-row resolution // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. - // - // [#23291] Naming no package, at the address {@link getMetaItem} - // serves its content from ({@link packagelessReadAddress}), so - // `effective` is what that read returns and `/published` serves - // the same document. Content only: the lock stays at the - // request's address, as the `_lock` gate judges it. - const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name); const served = await this.findServedOverlayRow({ type: request.type, name: request.name, From c9e2de86aeea881d8f96073db4567174608bbee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:00:02 +0000 Subject: [PATCH 7/9] fix(metadata-protocol): an object save's destructive-change check reads the row at the save's own address Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 66 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 11 ++++ 2 files changed, 77 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 82bda840991..3a09167ec1c 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -891,3 +891,69 @@ describe('[#23291] the read naming no package serves the row the save naming non expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pkg 2'); }); }); + +/** + * [#23291] The `object` save's destructive-change check (409 + * `DESTRUCTIVE_CHANGE`) judges the row the save overwrites: the read at the + * save's own address. With a package-unbound row stored before the package's, + * the check used to read naming no package and judge the unbound row, which no + * package-less save writes, and a save naming a third package was judged + * against whichever row was stored first. + */ +describe('[#23291] an object\'s destructive-change check judges the row the save overwrites', () => { + const objectBody = (name: string, fields: readonly string[]) => ({ + name, label: name, sharingModel: 'private', fields: Object.fromEntries(fields.map((f) => [f, { name: f, type: 'text' }])), + }); + /** Unbound `{a, d}` stored first, the package's `{a, c}` beside it. */ + function pairFixture(name: string) { + const fixture = makeProtocol(); + fixture.seedRow({ type: 'object', name, package_id: null, metadata: JSON.stringify(objectBody(name, ['a', 'd'])), checksum: 'sha-unbound' }); + fixture.seedRow({ type: 'object', name, package_id: WRITABLE, metadata: JSON.stringify(objectBody(name, ['a', 'c'])), checksum: 'sha-pkg' }); + return fixture; + } + const fieldsOf = (row: Row) => Object.keys(JSON.parse(row.metadata).fields ?? {}).sort(); + + it('a package-less save dropping a field only the package\'s row has is refused 409 DESTRUCTIVE_CHANGE, and accepted with force', async () => { + const name = 's6_obj_pkg_field'; + const { protocol, rows } = pairFixture(name); + const before = rows('object', name).map((r) => [r.package_id ?? null, fieldsOf(r)]); + + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'object', name, item: objectBody(name, ['a', 'd']), + }))); + expect(envelope).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); + expect(rows('object', name).map((r) => [r.package_id ?? null, fieldsOf(r)])).toEqual(before); + + await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'd']), force: true }); + expect(Object.fromEntries(rows('object', name).map((r) => [String(r.package_id ?? null), fieldsOf(r)]))) + .toEqual({ null: ['a', 'd'], [WRITABLE]: ['a', 'd'] }); + }); + + it('a package-less save dropping a field only the package-unbound row has is accepted: it writes the package\'s row', async () => { + const name = 's6_obj_unbound_field'; + const { protocol, rows } = pairFixture(name); + + const saved = await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'c']) }); + + expect(saved.success).toBe(true); + expect(Object.fromEntries(rows('object', name).map((r) => [String(r.package_id ?? null), fieldsOf(r)]))) + .toEqual({ null: ['a', 'd'], [WRITABLE]: ['a', 'c'] }); + }); + + it('a save naming a third package with no row of its own is judged against the package-unbound stand-in it overlays', async () => { + const name = 's6_obj_third_pkg'; + const { protocol, rows } = pairFixture(name); + + // Drops `c`, which only the OTHER package's row has: not this save's row. + const saved = await protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, ['a', 'd']), packageId: OTHER_WRITABLE }); + expect(saved.success).toBe(true); + expect(fieldsOf(rows('object', name).find((r) => r.package_id === OTHER_WRITABLE)!)).toEqual(['a', 'd']); + + // Drops `d`, which the stand-in it overlays has: refused. + const { protocol: second } = pairFixture(`${name}_2`); + const envelope = resolveThrownHttpError(await refusalOf(() => second.saveMetaItem({ + type: 'object', name: `${name}_2`, item: objectBody(`${name}_2`, ['a', 'c']), packageId: OTHER_WRITABLE, + }))); + expect(envelope).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ca9b0fe0365..ca6a1b3e806 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22068,9 +22068,20 @@ export class ObjectStackProtocolImplementation implements // reachable type set and carries the measurements above. if (!request.force && singularType === 'object') { try { + // [#23291, ADR-0048] Judged against the row this save + // overwrites: the read at the save's own address. A + // package-less publish save has resolved `packageId` to the + // item's one package above (#23165); a save naming a package + // with no row of its own reads that package's address, the + // package-unbound stand-in it overlays. Before, the read named + // no package, so with a package-unbound row stored beside the + // package's it judged a row no package-less save writes, and a + // save naming a third package was judged against another + // package's row. const existing = await this.getMetaItem({ type: request.type, name: request.name, + ...(request.packageId ? { packageId: request.packageId } : {}), } as any); const prev = (existing as any)?.item; if (prev) { From 8e3b9491c555681803f9ab82a9192eb8c169fe24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:01:19 +0000 Subject: [PATCH 8/9] changeset: name the destructive-change check and the in-process readers that follow the read Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .../23291-packageless-read-serves-save-address.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/23291-packageless-read-serves-save-address.md index a9aa8db49c6..35e421afa04 100644 --- a/.changeset/23291-packageless-read-serves-save-address.md +++ b/.changeset/23291-packageless-read-serves-save-address.md @@ -14,5 +14,12 @@ Clause-②: no - the draft reads, `?state=draft` and the draft preview, whose address comes from the same active rows, because the package-less draft save inherits that package. An item bound to two or more packages is read as before: the first stored row, ADR-0048 §3.3's best effort. A save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. The lock reported by these reads is selected as before. -- **One answer changes from 200 to 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. -- **What does not change.** No stored row is rewritten or removed. No key, export or schema moves. No write is refused that was accepted before. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. +- **What follows the read.** In-process readers of the item that name no package see the same row: the data doors' object reads (import reference targets, export and template fields), seed apply's object lookup, and the security catalog echo of a `permission` or `position`. +- **The `object` save's destructive-change check (`409 DESTRUCTIVE_CHANGE`) now judges the row the save overwrites**, read at the save's own address. Before, it read naming no package and judged the first stored row, which is the package-unbound row in the order that saves produce it. In this two-row state that moves its verdict in both directions: + - A package-less save that drops a field only the package's row has is now refused. Before, it was judged against the package-unbound row and accepted, and the field was dropped with no confirmation. + - A package-less save that drops a field only the package-unbound row has is now accepted. Before, it was refused over a row it does not write. + - A save naming a package with no row of its own is judged against the package-unbound row it overlays, never another package's row. + + The rule and its remedy (`force`) are unchanged. +- **The draft read's corner: a 200 becomes a 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. +- **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. Apart from the destructive-change check above, no write door's verdict moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. From 46282fff927bd0c6c3236009104aa5961e161675 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:56:08 +0000 Subject: [PATCH 9/9] test(metadata-protocol): the refused third-package destructive save leaves every row unchanged; changeset: no metadata write door's own rule moves Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude --- .changeset/23291-packageless-read-serves-save-address.md | 2 +- .../src/protocol.packageless-save-address.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/23291-packageless-read-serves-save-address.md index 35e421afa04..bf17451c2cc 100644 --- a/.changeset/23291-packageless-read-serves-save-address.md +++ b/.changeset/23291-packageless-read-serves-save-address.md @@ -22,4 +22,4 @@ Clause-②: no The rule and its remedy (`force`) are unchanged. - **The draft read's corner: a 200 becomes a 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. -- **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. Apart from the destructive-change check above, no write door's verdict moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. +- **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. No metadata write door's own rule moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 3a09167ec1c..f48c38f9720 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -950,10 +950,12 @@ describe('[#23291] an object\'s destructive-change check judges the row the save expect(fieldsOf(rows('object', name).find((r) => r.package_id === OTHER_WRITABLE)!)).toEqual(['a', 'd']); // Drops `d`, which the stand-in it overlays has: refused. - const { protocol: second } = pairFixture(`${name}_2`); + const { protocol: second, rows: secondRows } = pairFixture(`${name}_2`); + const secondBefore = secondRows('object', `${name}_2`).map((r) => [r.package_id ?? null, fieldsOf(r)]); const envelope = resolveThrownHttpError(await refusalOf(() => second.saveMetaItem({ type: 'object', name: `${name}_2`, item: objectBody(`${name}_2`, ['a', 'c']), packageId: OTHER_WRITABLE, }))); expect(envelope).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); + expect(secondRows('object', `${name}_2`).map((r) => [r.package_id ?? null, fieldsOf(r)])).toEqual(secondBefore); }); });