From ef8ae7e3a91695a52b0c86c35f3aceb56f63c543 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:32:49 +0000 Subject: [PATCH 1/5] test(metadata-protocol): pin the strict draft read naming no package to the draft its version names (WIP) Claude-Session: https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 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 505949d6c90..9afc3d0b95d 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -1240,3 +1240,118 @@ describe('[#23495] the draft doors naming no package act on the draft the draft }); }); }); + +describe('[#23715] the strict draft read naming no package serves its content from the draft its version names', () => { + const P = WRITABLE; + const Q = OTHER_WRITABLE; + const R = 'com.qa.thirdapp'; + type Fixture = ReturnType & { name: string }; + + /** `label@binding` of every draft row of the item, in storage order. */ + const drafts = (f: Fixture) => + f.rows('app', f.name, 'draft').map((r) => `${JSON.parse(r.metadata).label}@${r.package_id ?? 'null'}`); + const readDraft = (f: Fixture) => f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft' }); + /** The draft read naming the row's own package (`null`: none), the read whose content and `version` are one row's. */ + const readDraftAt = (f: Fixture, packageId: string) => + f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft', packageId }); + const unboundDraft = (f: Fixture, label: string) => f.seedRow({ + type: 'app', name: f.name, state: 'draft', package_id: null, + metadata: JSON.stringify(app(f.name, { label })), checksum: `sha-${label}`, + }); + + /** + * No active row; drafts `[package-unbound, Q]` in either storage order. + * The package-unbound draft is reached through the door when it is stored + * first (a draft save naming no package of a new item); stored second it + * is seeded, as an older build left it (#23657: that save now updates Q's + * only draft). + */ + async function unboundBesideQ(name: string, unboundFirst: boolean): Promise { + const f = { ...makeProtocol(), name }; + if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'q' }), packageId: Q, mode: 'draft' }); + if (!unboundFirst) unboundDraft(f, 'unbound'); + return f; + } + + for (const unboundFirst of [true, false]) { + const order = unboundFirst ? 'the package-unbound draft stored first' : 'Q\'s draft stored first'; + + it(`drafts [package-unbound, Q], no active row (${order}): content and version are the package-unbound draft's, and an If-Match save edits the row read`, async () => { + const f = await unboundBesideQ(`u23715_${unboundFirst ? 'unbound_first' : 'q_first'}`, unboundFirst); + expect(drafts(f)).toEqual(unboundFirst ? ['unbound@null', `q@${Q}`] : [`q@${Q}`, 'unbound@null']); + expect(f.rows('app', f.name, 'active')).toEqual([]); + + const read = await readDraft(f); + expect(read.item.label).toBe('unbound'); + expect(read.item._packageId).toBeUndefined(); + expect(read.version).toEqual(expect.any(String)); + // Q's draft is still reachable by naming Q, under its own token. + const q = await readDraftAt(f, Q); + expect(q.item.label).toBe('q'); + expect(q.version).not.toBe(read.version); + + // The token names the row read: a save pinned to it edits that row, and Q's draft is untouched. + const saved = await f.protocol.saveMetaItem({ + type: 'app', name: f.name, item: app(f.name, { label: 'edited' }), mode: 'draft', parentVersion: read.version, + }); + expect(saved.success).toBe(true); + expect(drafts(f)).toEqual(unboundFirst ? ['edited@null', `q@${Q}`] : [`q@${Q}`, 'edited@null']); + const again = await readDraft(f); + expect(again.item.label).toBe('edited'); + expect(again.version).toBe(saved.version); + }); + } + + it('active rows bound to two packages, drafts [package-unbound, P] in either storage order: the read serves the package-unbound draft its version names', async () => { + for (const unboundFirst of [true, false]) { + const name = `u23715_two_active_${unboundFirst ? 'unbound_first' : 'p_first'}`; + const f = { ...makeProtocol(), name }; + if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live P' }), packageId: P }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live Q' }), packageId: Q }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'p' }), packageId: P, mode: 'draft' }); + if (!unboundFirst) unboundDraft(f, 'unbound'); + expect(drafts(f), name).toEqual(unboundFirst ? ['unbound@null', `p@${P}`] : [`p@${P}`, 'unbound@null']); + + const read = await readDraft(f); + expect(read.item.label, name).toBe('unbound'); + expect(read.version, name).toEqual(expect.any(String)); + expect(read.version, name).not.toBe((await readDraftAt(f, P)).version); + } + }); + + it('CONTROL: a single draft is served as before — the package-unbound one, or the one package\'s', async () => { + const u = { ...makeProtocol(), name: 'u23715_only_unbound' }; + const saved = await u.protocol.saveMetaItem({ type: 'app', name: u.name, item: app(u.name, { label: 'unbound' }), mode: 'draft' }); + const ur = await readDraft(u); + expect(ur.item.label).toBe('unbound'); + expect(ur.version).toBe(saved.version); + + const q = { ...makeProtocol(), name: 'u23715_only_q' }; + const qSaved = await q.protocol.saveMetaItem({ type: 'app', name: q.name, item: app(q.name, { label: 'q' }), packageId: Q, mode: 'draft' }); + const qr = await readDraft(q); + expect(qr.item).toMatchObject({ label: 'q', _packageId: Q }); + expect(qr.version).toBe(qSaved.version); + }); + + it('CONTROL: an item with an active binding serves the draft of its package, beside another package\'s draft', async () => { + const f = { ...makeProtocol(), name: 'u23715_bound' }; + await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' }); + await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'live' }), packageId: P }); + await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'p' }), mode: 'draft' }); + expect(drafts(f)).toEqual([`q@${Q}`, `p@${P}`]); + const read = await readDraft(f); + expect(read.item).toMatchObject({ label: 'p', _packageId: P }); + expect(read.version).toBe((await readDraftAt(f, P)).version); + }); + + it('CONTROL: drafts bound to two packages and none package-unbound are still served best-effort, with no version (the save naming none is refused there)', async () => { + const f = { ...makeProtocol(), name: 'u23715_two_drafts' }; + await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' }); + await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'r' }), packageId: R, mode: 'draft' }); + const read = await readDraft(f); + expect(['q', 'r']).toContain(read.item.label); + expect(read.version).toBeNull(); + }); +}); From e838e9af723415ff0149b833dbffa3bcb32b23a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:40:38 +0000 Subject: [PATCH 2/5] fix(metadata-protocol): the strict draft read naming no package serves the draft its version names Over drafts [package-unbound, Q] with no active row, GET ?state=draft served its version from the package-unbound draft (headAt) but its content from the first draft row a package-agnostic lookup returned. The read now takes its content from packagelessDraftAddress, the address the draft save, the per-item publish and the draft discard naming no package use. Claude-Session: https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 87 +++++++++++++++++-- ...eta-draft-head-package-inheritance.test.ts | 66 ++++++++++++++ 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7b25a896d02..8924726eaf0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2578,10 +2578,17 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { - readonly packageId: string | undefined; + /** + * [#23715] `null` addresses the package-unbound row alone: the strict + * draft read naming no package, at the draft its `version` names + * ({@link ObjectStackProtocolImplementation.strictDraftReadAddress}). + */ + readonly packageId: string | null | undefined; }): ServedOverlayRowCandidate[] { const scopes: Array> = [{ scope: 'env', organizationId: null }]; - const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; + const packages: Array = address.packageId === null + ? [null] + : address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => packages.map((packageId) => ({ ...scope, spelling, packageId })))); } @@ -10644,7 +10651,8 @@ export class ObjectStackProtocolImplementation implements type: string; name: string; state: 'active' | 'draft'; - packageId?: string; + /** [#23715] `null`: the package-unbound row alone (see {@link servedOverlayRowCandidates}). */ + packageId?: string | null; /** [#22877] The bundle member a by-name door's selector named; omitted, the canonical one. */ member?: string; }): Promise<{ row: any } | undefined> { @@ -10898,7 +10906,21 @@ 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.packagelessReadAddress(request.type, request.name, member); + // + // [#23715, ADR-0048, ADR-0070 TL;DR 1] The strict draft read that names + // no package takes its content from the draft its `version` names + // ({@link strictDraftReadAddress}), the address the draft save, the + // per-item publish and the draft discard naming none act on. The + // active rows above say nothing about an item never published into a + // package, so with drafts `[package-unbound, Q]` this read served the + // first draft row the store returned under the package-unbound + // draft's token, and a save pinned to that token wrote a row other + // than the one read (#8907). + const strictDraftAddress = readState === 'draft' && !request.packageId + ? await this.strictDraftReadAddress(request.type, request.name, member) + : undefined; + const contentPackageId = request.packageId + || (strictDraftAddress ? undefined : 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 @@ -10957,13 +10979,27 @@ export class ObjectStackProtocolImplementation implements // repair's `deletable` reads ({@link servedLockState}). let storedRowServed = false; try { - const record = (await this.findServedOverlayRow({ + let record = (await this.findServedOverlayRow({ type: request.type, name: request.name, state: readState, - ...(contentPackageId ? { packageId: contentPackageId } : {}), + ...(strictDraftAddress + ? { packageId: strictDraftAddress.packageId } + : contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; + // [#23715] Two or more packages and no package-unbound draft: the + // best-effort first match ADR-0048 §3.3 states, as before, served + // with `version: null` (no row at the address the save naming none + // resolves, and that save is refused there). + if (!record && strictDraftAddress?.bestEffort) { + record = (await this.findServedOverlayRow({ + type: request.type, + name: request.name, + state: readState, + ...memberOption(member), + }))?.row; + } storedRowServed = record !== undefined && record !== null; // [#20946, #21922] The stored-row half — see `storedRowDeclined` above. if (record && !storedRowDeclined) { @@ -19025,12 +19061,45 @@ export class ObjectStackProtocolImplementation implements * 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. + * layered read's `effective` and `/published` serve one row. [#23715] The + * strict draft read asks {@link strictDraftReadAddress} instead: the draft + * its `version` names. */ private async packagelessReadAddress(type: string, name: string, member?: string): Promise { return (await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member)).packageId ?? undefined; } + /** + * [#23715, ADR-0048, ADR-0070 TL;DR 1] The package the strict draft read + * that names no package (`?state=draft`) takes its CONTENT from: the draft + * its `version` names ({@link readVersionToken}, through + * `SysMetadataRepository.headAt`), which is the draft + * {@link packagelessDraftAddress} gives the draft save, the per-item + * publish and the draft discard naming none. `packageId` is that draft's + * binding, `null` for the package-unbound draft alone. + * + * An `ambiguous` address (two or more packages, refused by those doors) + * keeps what the read served before: the package-unbound draft, the row + * `headAt` answers there, else (`bestEffort`) the first draft row of any + * package, the best-effort first match ADR-0048 §3.3 states, served with + * `version: null`. + * + * Before, the read took its content from the item's ACTIVE rows + * ({@link packagelessReadAddress}). Over an item never published into a + * package those name no package, so the content was the first draft row + * a package-agnostic lookup returned (#8907) while the `version` was the + * package-unbound draft's. + */ + private async strictDraftReadAddress( + type: string, + name: string, + member?: string, + ): Promise<{ packageId: string | null; bestEffort: boolean }> { + const address = await this.packagelessDraftAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member); + if (address.kind === 'ambiguous') return { packageId: null, bestEffort: true }; + return { packageId: address.key, bestEffort: false }; + } + /** * [#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 @@ -19119,8 +19188,8 @@ export class ObjectStackProtocolImplementation implements * per-item publish ({@link promoteDraftForPublish}) and the draft discard * ({@link deleteMetaItem} with `state: 'draft'`) take it, so they act on * the draft the draft save naming no package writes (#11087, - * `SysMetadataRepository.resolveWriteHead`) and the draft read naming no - * package serves (#23291, {@link packagelessReadAddress}). + * `SysMetadataRepository.resolveWriteHead`) and the strict draft read + * naming no package serves ([#23715] {@link strictDraftReadAddress}). * * The address is the draft save's, read from the item's ACTIVE rows * ({@link packagelessAddress}): diff --git a/packages/rest/src/meta-draft-head-package-inheritance.test.ts b/packages/rest/src/meta-draft-head-package-inheritance.test.ts index d663055de4c..f0608266893 100644 --- a/packages/rest/src/meta-draft-head-package-inheritance.test.ts +++ b/packages/rest/src/meta-draft-head-package-inheritance.test.ts @@ -645,3 +645,69 @@ describe('[#23657] an item never published into a package: the draft save naming expect(await beside.stored('draft')).toHaveLength(3); }, 60_000); }); + +describe('[#23715] the strict draft read naming no package serves its content from the draft its version names, on the real routes', () => { + const Q = 'com.probe.q'; + + /** + * No active row; drafts `[package-unbound, Q]` in either storage order. + * Stored second, the package-unbound draft is seeded, as an older build + * left it (#23657: a draft save naming no package now updates Q's only + * draft). + */ + const ORDERS = { + 'the package-unbound draft saved first': async () => { + const h = await boot(); + ok(await h.save(view('unbound'), { mode: 'draft' })); + ok(await h.save(view('q'), { mode: 'draft', package: Q })); + return h; + }, + 'Q\'s draft saved first': async () => { + const h = await boot(); + ok(await h.save(view('q'), { mode: 'draft', package: Q })); + await h.seed('draft', null, view('unbound')); + return h; + }, + // Both seeded, Q's first, in insertion order and in id order + // (`seed_draft_com.probe.q` < `seed_draft_unbound`). Measured: this + // store still answers a package-agnostic lookup with the + // package-unbound row, whose NULL `package_id` sorts first in + // `sys_metadata`'s `(type, name, package_id, bundle_member)` index, so + // here content and version agreed before the fix too, by index order + // alone; the protocol suite's in-memory double is the order that + // diverged. + 'both seeded, Q\'s first': async () => { + const h = await boot(); + await h.seed('draft', Q, view('q')); + await h.seed('draft', null, view('unbound')); + return h; + }, + } as const; + + for (const [order, build] of Object.entries(ORDERS)) { + it(`drafts [package-unbound, Q] (${order}): content and version are the package-unbound draft's, and an If-Match save edits the row read`, async () => { + const h = await build(); + const read = ok(await h.read({ state: 'draft' })); + expect(read.item.label).toBe('unbound'); + expect(read.version).toMatch(KEYED); + const q = ok(await h.read({ state: 'draft', package: Q })); + expect(q.item.label).toBe('q'); + expect(q.version).not.toBe(read.version); + + ok(await h.save(view('edited'), { mode: 'draft' }, { 'if-match': read.version })); + expect(await h.stored('draft')).toEqual(expect.arrayContaining([ + { label: 'edited', packageId: null }, { label: 'q', packageId: Q }, + ])); + expect(await h.stored('draft')).toHaveLength(2); + expect(ok(await h.read({ state: 'draft' })).item.label).toBe('edited'); + }, 60_000); + } + + it('control: a single draft is served with the version its save answered', async () => { + const h = await boot(); + const saved = ok(await h.save(view('only'), { mode: 'draft', package: Q })); + const read = ok(await h.read({ state: 'draft' })); + expect(read.item.label).toBe('only'); + expect(read.version).toBe(saved.version); + }, 60_000); +}); From 4b03f3044db1799797fe0a3b9f3cd60c7efc802d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:42:27 +0000 Subject: [PATCH 3/5] fix(metadata-protocol): the draft-overlay preview naming no package serves the draft the strict read serves Claude-Session: https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 3 ++ packages/metadata-protocol/src/protocol.ts | 29 +++++++++++++------ 2 files changed, 23 insertions(+), 9 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 9afc3d0b95d..68a613f6fc4 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -1300,6 +1300,9 @@ describe('[#23715] the strict draft read naming no package serves its content fr const again = await readDraft(f); expect(again.item.label).toBe('edited'); expect(again.version).toBe(saved.version); + // The draft-overlay preview naming no package serves the same draft. + expect((await f.protocol.getMetaItem({ type: 'app', name: f.name, previewDrafts: true })).item) + .toMatchObject({ label: 'edited', _draft: true }); }); } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8924726eaf0..a54a42e4438 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10915,12 +10915,13 @@ export class ObjectStackProtocolImplementation implements // package, so with drafts `[package-unbound, Q]` this read served the // first draft row the store returned under the package-unbound // draft's token, and a save pinned to that token wrote a row other - // than the one read (#8907). - const strictDraftAddress = readState === 'draft' && !request.packageId + // than the one read (#8907). The draft-overlay preview below shares + // the address: it serves the draft the strict read serves. + const draftContentAddress = !request.packageId && (readState === 'draft' || request.previewDrafts) ? await this.strictDraftReadAddress(request.type, request.name, member) : undefined; const contentPackageId = request.packageId - || (strictDraftAddress ? undefined : await this.packagelessReadAddress(request.type, request.name, member)); + || (readState === 'draft' ? undefined : 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 @@ -10933,13 +10934,22 @@ export class ObjectStackProtocolImplementation implements // [#21716] The served-row resolution, on the draft partition // (ADR-0048 prefer-local, parity with the row read below) — see // {@link findServedOverlayRow}. - const draftRec = (await this.findServedOverlayRow({ + let draftRec = (await this.findServedOverlayRow({ type: request.type, name: request.name, state: 'draft', - ...(contentPackageId ? { packageId: contentPackageId } : {}), + ...(draftContentAddress + ? { packageId: draftContentAddress.packageId } + : contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; + // [#23715] Two or more packages and no package-unbound draft: + // the best-effort first match, as the strict read below. + if (!draftRec && draftContentAddress?.bestEffort) { + draftRec = (await this.findServedOverlayRow({ + type: request.type, name: request.name, state: 'draft', ...memberOption(member), + }))?.row; + } if (draftRec) { const draftItem = this.convertStoredItem( String(draftRec.type ?? request.type), @@ -10983,8 +10993,8 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: readState, - ...(strictDraftAddress - ? { packageId: strictDraftAddress.packageId } + ...(readState === 'draft' && draftContentAddress + ? { packageId: draftContentAddress.packageId } : contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; @@ -10992,7 +11002,7 @@ export class ObjectStackProtocolImplementation implements // best-effort first match ADR-0048 §3.3 states, as before, served // with `version: null` (no row at the address the save naming none // resolves, and that save is refused there). - if (!record && strictDraftAddress?.bestEffort) { + if (!record && readState === 'draft' && draftContentAddress?.bestEffort) { record = (await this.findServedOverlayRow({ type: request.type, name: request.name, @@ -19071,7 +19081,8 @@ export class ObjectStackProtocolImplementation implements /** * [#23715, ADR-0048, ADR-0070 TL;DR 1] The package the strict draft read - * that names no package (`?state=draft`) takes its CONTENT from: the draft + * that names no package (`?state=draft`), and the draft-overlay preview + * naming none (`previewDrafts`), take their draft CONTENT from: the draft * its `version` names ({@link readVersionToken}, through * `SysMetadataRepository.headAt`), which is the draft * {@link packagelessDraftAddress} gives the draft save, the per-item From 3faac8a3ec31be88280a6d05eeda05c02ac9be1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:42:28 +0000 Subject: [PATCH 4/5] fix(spec): the per-item publish's packageId description carries the save's no-package-unbound-draft qualifier Description only. Claude-Session: https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 1cea389fcb6..16881ee63aa 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1056,7 +1056,8 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ + 'exists, as the strict draft read does. For an item never published into ' + 'a package it is the package-unbound draft, else the one package\'s ' + 'draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, ' - + 'with none, the drafts) are bound to two or more packages. ⚠️ `null` is ' + + 'with none and no package-unbound draft, the drafts) are bound to two or ' + + 'more packages. ⚠️ `null` is ' + 'NOT the same as absent: the implementation branches on the KEY BEING ' + 'PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread ' + 'it in conditionally; a present-and-`undefined` key coerces to `null` ' From eaa4b0ed563a796fbb1722342de936f8a8ee65d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:51:08 +0000 Subject: [PATCH 5/5] chore: regenerate the protocol reference docs and add the changeset Claude-Session: https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub Co-authored-by: Claude --- .changeset/23715-strict-draft-read-address.md | 14 ++++++++++++++ content/docs/references/api/protocol.mdx | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/23715-strict-draft-read-address.md diff --git a/.changeset/23715-strict-draft-read-address.md b/.changeset/23715-strict-draft-read-address.md new file mode 100644 index 00000000000..aa597e1d350 --- /dev/null +++ b/.changeset/23715-strict-draft-read-address.md @@ -0,0 +1,14 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +--- + +fix(metadata-protocol): the strict draft read naming no package (`GET /api/v1/meta/TYPE/NAME?state=draft`) serves its content from the draft its `version` names, the draft the draft save, the per-item publish and the draft discard naming no package act on (ADR-0048, ADR-0070) + +Clause-②: yes + +A `sys_metadata` row is keyed by its package (ADR-0048), so an item never published into a package can hold a package-unbound draft beside package `Q`'s. The strict draft read naming no package answered its `version` from the package-unbound draft (the draft the save naming no package writes), but its content from whichever draft row the store returned first. On a store that returned `Q`'s row first, it served `Q`'s body under the package-unbound draft's token, and a save pinned to that token (`If-Match`) wrote the package-unbound draft, a different row from the one read. + +The read now takes its content from the same address as its `version`, in every storage order: the package-unbound draft when one is stored, else the one package's draft; for an item whose active rows are bound to one package, that package's draft as before. The draft-overlay preview naming no package (`previewDrafts`) serves the same draft. Drafts bound to two or more packages with no package-unbound draft are still served best-effort, with `version: null`, as before (the save naming no package is refused there). No accept set changes: no request that was answered `200` is refused now, and `?package=` still reaches each package's draft. + +`@objectstack/spec`: the description of `PublishMetaItemRequestSchema.packageId` now says, as `SaveMetaItemRequestSchema.packageId`'s does, that a publish naming no package is refused for drafts bound to two or more packages only when no package-unbound draft is stored. Description only. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 13b26951251..15b9df0e4c9 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2442,7 +2442,7 @@ Installed package with runtime lifecycle state | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | -| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. | | **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |