From b411a4c54af702b79b919ccec51cd87fb86d78cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:48:23 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): the per-item publish and the draft discard that name no package act on the draft the draft save writes WIP: the draft address helper, the publish and discard routing, the orphan-draft adoption on promotion, the publish arm of the shared ambiguity refusal, and the draft-save receipt naming the inherited package. Tests and changeset follow. Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 299 ++++++++++++++---- .../src/sys-metadata-repository.ts | 45 ++- 2 files changed, 275 insertions(+), 69 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 389ca76a105..38cb12e02e9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1217,6 +1217,33 @@ function remainingRowsPhrase(packageIds: readonly string[], unbound: boolean, no return `${parts.join(' and ')} ${count === 1 ? 'remains' : 'remain'}`; } +/** + * [#23495, ADR-0048] The `404 NO_DRAFT` of a per-item publish that names no + * package, of an item bound to package `P` that holds no draft at `P`'s + * address ({@link ObjectStackProtocolImplementation.packagelessDraftAddress}) + * while drafts bound to other packages are stored. The code and status are the + * promotion's own, the ones the strict draft read naming no package answers + * in the same state; the sentence names the drafts that remain and the + * request that reaches them, prescription first (a 4xx message crosses the + * REST boundary bounded at 500 characters). + */ +function packagelessPublishNoDraftRefusal( + type: string, + name: string, + packageId: string, + otherPackageIds: readonly string[], +): Error & { code: 'NO_DRAFT'; status: 404 } { + const err = new Error( + 'Name the package of the draft to publish, then send it again: on the REST door add ?package= with the ' + + `package id, in process pass packageId. Nothing was published. ${type} '${name}' has no pending draft ` + + `bound to package '${packageId}', the package its published rows are bound to and the draft a publish ` + + `that names no package promotes, and ${remainingRowsPhrase(otherPackageIds, false, 'draft')}.`, + ) as Error & { code: 'NO_DRAFT'; status: 404 }; + err.code = 'NO_DRAFT'; + err.status = 404; + return err; +} + /** * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape * Studio's designer keys on to highlight the offending form control. @@ -18947,9 +18974,12 @@ export class ObjectStackProtocolImplementation implements * The one resolution its three readers take, so they cannot disagree: * {@link saveMetaItem}'s direct (active) save, which then writes as a save * naming that package does; {@link deleteMetaItem}, which then removes as a - * delete naming that package does, its draft discard reading the draft - * rows; and {@link readVersionToken}, whose `version` a save or a delete at - * the same address accepts. + * delete naming that package does; and {@link readVersionToken}, whose + * `version` a save or a delete at the same address accepts. [#23495] The + * draft doors' address is {@link packagelessDraftAddress}, which reads it + * at both states: the draft discard used to read it from the draft rows + * alone, so it removed another package's draft than the one the draft + * save writes. * * Why the package's row: a write addresses exactly one * `(org, type, name, package_id)` row, and the item read that names no @@ -19013,6 +19043,77 @@ export class ObjectStackProtocolImplementation implements return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates, unbound }; } + /** + * [#23495, ADR-0048, ADR-0070 TL;DR 1] THE package-less DRAFT address: + * which draft of an item a draft door that names no package acts on. The + * 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}). + * + * The address is the draft save's, read from the item's ACTIVE rows + * ({@link packagelessAddress}): + * + * - bound to one package `P`: `P`'s draft, else the package-unbound draft, + * which the draft save adopts into `P` on its next write and the read + * serves in `P`'s place (ADR-0048 prefer-local). A publish promotes such + * a draft INTO `P` (`into`), the row the read naming no package serves + * next. Neither present: no draft (`none`), whatever other packages' + * drafts are stored; the strict draft read answers `404 NO_DRAFT` here + * too, and those drafts are addressed by naming their package. + * - bound to two or more packages: `ambiguous`, refused by both doors + * with the refusal the draft save answers + * ({@link packagelessAddressAmbiguityRefusal}, `409 RESOURCE_CONFLICT`). + * - bound to none (an item never published into a package): the + * package-unbound draft, the one the draft save writes. With no such + * draft, the one package's draft when exactly one package holds a draft + * (the only draft the read naming no package can serve); two or more + * is `ambiguous`; none at all is `none`. + * + * Every row is read in full, never sampled: a package-agnostic `findOne` + * over two drafts is a driver-order coin toss (#8907). The per-item publish + * took the first draft row that `findOne` met, and the discard read the + * draft rows alone. Over an item bound to `P` both acted on another + * package's draft than the one the save writes, and the publish could + * promote a package-unbound draft into an active row no read naming no + * package serves. + * + * `key` is the stored binding of the draft row acted on (`null` for the + * package-unbound draft), `into` the package its promotion is written + * under, and `drafts` the bindings of every draft row of the item, which + * the doors' receipts name. + */ + private async packagelessDraftAddress( + type: string, + name: string, + /** [#22877] The bundle member the door addresses; omitted, the canonical one. */ + member?: string, + ): Promise< + | { kind: 'ambiguous'; candidates: readonly string[] } + | { + kind: 'draft' | 'none'; + key: string | null; + into: string | null; + drafts: { packageId: string | null; candidates: readonly string[]; unbound: boolean }; + } + > { + const active = await this.packagelessAddress(type, name, member, 'active'); + if (active.candidates.length > 1) return { kind: 'ambiguous', candidates: active.candidates }; + const drafts = await this.packagelessAddress(type, name, member, 'draft'); + const own = active.packageId; + if (own !== null) { + if (drafts.candidates.includes(own)) return { kind: 'draft', key: own, into: own, drafts }; + if (drafts.unbound) return { kind: 'draft', key: null, into: own, drafts }; + return { kind: 'none', key: own, into: own, drafts }; + } + if (drafts.unbound) return { kind: 'draft', key: null, into: null, drafts }; + if (drafts.candidates.length > 1) return { kind: 'ambiguous', candidates: drafts.candidates }; + const only = drafts.candidates[0]; + if (only !== undefined) return { kind: 'draft', key: only, into: only, drafts }; + return { kind: 'none', key: null, into: null, drafts }; + } + /** * Resolve a caller's version token to the STORED head it names, for a write * whose current stored head is `currentStored`: the token must equal the @@ -22912,6 +23013,11 @@ export class ObjectStackProtocolImplementation implements org: 'env', } as Parameters[0]; let parentVersion: string | null; + // [#23495] The package a draft save naming no package writes into, as + // the repository resolves it (#11087: the package of the item's active + // rows), read off the same head read the parent comes from; the + // receipt names it, as the direct save's names its package (#23165). + let inheritedDraftPackageId: string | null = null; if (request.storedParentVersion !== undefined) { // [#21207] An in-process caller that read the stored hash itself. parentVersion = request.storedParentVersion; @@ -22923,15 +23029,20 @@ export class ObjectStackProtocolImplementation implements // package the upsert targets so a collision's other-package // row is never read as this item's parent. // [#22114] The head the item read's `version` is the keyed form of - // — one read for both halves ({@link storedHeadAt}). [#22128] The + // — one read for both halves (`repo.headAt`, the read + // {@link storedHeadAt} makes for the item read). [#22128] The // row the repository's `put` resolves for this address and locks - // against, the inherited draft binding included. - const currentStored = await this.storedHeadAt( - repo, ref, mode === 'draft' ? 'draft' : 'active', request.packageId ?? null, + // against, the inherited draft binding included. [#23495] Read + // whole here, since its `packageId` is the receipt's too. + const writeHead = await repo.headAt(ref, { + state: mode === 'draft' ? 'draft' : 'active', + packageId: request.packageId ?? null, // [#22877] The write's bundle member, from the body it writes — // the member the repository's `put` keys this save by. - itemDiscriminator(singularTypeForRepo, request.item), - ); + member: itemDiscriminator(singularTypeForRepo, request.item), + }); + const currentStored = writeHead.head?.hash ?? null; + if (mode === 'draft' && request.packageId == null) inheritedDraftPackageId = writeHead.packageId; if (request.parentVersion === undefined) { parentVersion = currentStored; } else { @@ -23075,9 +23186,10 @@ export class ObjectStackProtocolImplementation implements // [#22877] The saved body's member — the row this save wrote. ...mutationEventMemberOfBody(singularTypeForRepo, request.item), }); - const receiptPackage = typeof request.packageId === 'string' && request.packageId !== '' - ? `, package=${request.packageId}` - : ''; + const receiptPackageId = typeof request.packageId === 'string' && request.packageId !== '' + ? request.packageId + : inheritedDraftPackageId; + const receiptPackage = receiptPackageId ? `, package=${receiptPackageId}` : ''; return { success: true, // [#21207] The version token: the keyed form of the stored @@ -23137,8 +23249,10 @@ export class ObjectStackProtocolImplementation implements // [#23165] …and it names the package whose row this save wrote, // when the save's address names one: the package the caller // named, or the one a direct save naming none resolved to - // ({@link packagelessAddress}). A save into the - // package-unbound row reads exactly as it did. + // ({@link packagelessAddress}), or [#23495] the one a draft + // save naming none inherits from the item's active rows + // (#11087). A save into the package-unbound row reads exactly + // as it did. message: artifactBacked ? `Saved customization overlay (env-wide${receiptPackage}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` : `Saved ${singularTypeForRepo} '${request.name}' (env-wide${receiptPackage}, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`, @@ -23862,8 +23976,12 @@ export class ObjectStackProtocolImplementation implements * ⚠️ `null` is NOT the same as absent, and the difference is load * bearing the whole way down: {@link promoteDraftForPublish} branches on * the KEY BEING PRESENT (`'packageId' in request`), so an ABSENT key - * keeps the historical "match any package" resolution while `null` pins - * the lookup to the UNBOUND row. Spread it in conditionally; never write + * names no package while `null` pins the lookup to the UNBOUND row. + * [#23495] Naming no package is no longer "match any package": the + * publish promotes the draft a draft save naming no package writes + * ({@link packagelessDraftAddress}), and is refused `409 + * RESOURCE_CONFLICT` when the item's active rows are bound to two or + * more packages. Spread it in conditionally; never write * `packageId: maybeUndefined`, which arrives as a present-and-undefined * key, coerces to `null` downstream, and makes a package-bound draft * unfindable — a silent `no_draft` on the untouched path. @@ -24159,8 +24277,10 @@ export class ObjectStackProtocolImplementation implements * `repo.promoteDraft` so the promotion resolves the draft under the * SAME key it was listed by. * - * `undefined` (any caller with no binding to state) keeps the - * historical "match any package" resolution. `null` pins the lookup to + * `undefined` (any caller with no binding to state) names no package: + * [#23495] the draft the draft save naming no package writes + * ({@link packagelessDraftAddress}), never the first draft row a + * package-agnostic read meets. `null` pins the lookup to * the unbound row — so the field is passed through only when the caller * actually has a binding to state. [commit 490879ad0] That parenthetical used to * read "the `publishMetaItem` path, which names no package"; since @@ -24231,37 +24351,49 @@ export class ObjectStackProtocolImplementation implements // of the same name in one org, and a read without the package // dimension picks either. The key is the caller's stated binding // (spelled exactly as `repo.promoteDraft` receives it); with none - // stated, the binding of the draft row this promotion resolves, read - // once and then stated to the lock lookup, the read and the promotion. - let draftKey: string | null | undefined = 'packageId' in request ? (request.packageId ?? null) : undefined; - if (draftKey === undefined) { + // stated, the key of the draft this promotion resolves, read once and + // then stated to the lock lookup, the read and the promotion. + // + // [#23495, ADR-0048, ADR-0070 TL;DR 1] With none stated, the draft the + // draft save naming no package writes ({@link packagelessDraftAddress}). + // It used to be the binding of whichever draft row a package-agnostic + // `findOne` met first, so over an item bound to package `P` the publish + // promoted the package-unbound draft beside `P`'s, into a package-less + // active row no read naming no package serves, or another package's + // draft, into a second package's active row, while the read, the save + // and the token named `P`'s draft. `promoteInto` is the package the + // active row is written under: the key, or for an unbound draft of an + // item bound to `P`, `P` (the adoption the draft save performs). + let draftKey: string | null; + let promoteInto: string | null; + // Answered below the `_lock` gate, which judges the item rather than + // its row, as the save and the delete order their address refusals. + let addressRefusal: Error | undefined; + if ('packageId' in request) { + draftKey = request.packageId ?? null; + promoteInto = draftKey; + } else { // [#21934] Read ahead of the lock check, so a store that cannot be // read is answered here as the lock read answers it (#5706): an // unprovisioned `sys_metadata` holds no draft, and any other failure - // is the 503 the lock read raised before this read moved above it. - let draftRow: unknown; - try { - // [#21908, ADR-0096 D5] The explicit system opt-in: a platform - // store read, which no caller's grants scope. The publish door - // already authorized the caller, the request carries no - // execution context to forward (only its `actor`), and the - // protocol scopes the row itself ({@link findEnvironmentRow}), - // exactly as its sibling store reads do. Principal-less, the - // engine now refuses it. - draftRow = await this.findEnvironmentRow('sys_metadata', { - where: { - type: singularType, name: request.name, state: 'draft', - // [#22877] The addressed member's draft (the canonical - // one when none was named) — the one `repo.get` / - // `repo.promoteDraft` below address. - ...overlayMemberWhere(singularType, request.member), - }, - context: { isSystem: true }, - }); - } catch (error) { - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + // is the 503 the lock read raised before this read moved above it + // ({@link packagelessAddress} discriminates both). + const address = await this.packagelessDraftAddress(singularType, request.name, request.member); + if (address.kind === 'ambiguous') { + draftKey = null; + promoteInto = null; + addressRefusal = packagelessAddressAmbiguityRefusal('publish', request.type, request.name, address.candidates); + } else { + draftKey = address.key; + promoteInto = address.into; + // No draft at the address while another package's draft is + // stored: the promotion's `NO_DRAFT`, naming the drafts that + // remain and the request that reaches them. + const others = address.drafts.candidates.filter((id) => id !== address.key); + if (address.kind === 'none' && address.into !== null && others.length > 0) { + addressRefusal = packagelessPublishNoDraftRefusal(request.type, request.name, address.into, others); + } } - if (draftRow) draftKey = (draftRow as { package_id?: string | null }).package_id ?? null; } // ADR-0010 L3 — lock blocks publish too (publishing is a write). // @@ -24274,8 +24406,10 @@ export class ObjectStackProtocolImplementation implements // [#21761] The promotion's address carries its package, as the read's does. // [#21934] It is the key resolved above, the one the gate reads the // draft under and the promotion writes under: the caller's stated - // binding, else the resolved draft row's own. - ...(draftKey ? { packageId: draftKey } : {}), + // binding, else the resolved draft's. [#23495] The package the + // promotion writes under, which for an adopted unbound draft is the + // item's package. + ...(promoteInto ? { packageId: promoteInto } : {}), operation: 'publish', ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.publishMetaItem', @@ -24283,9 +24417,10 @@ export class ObjectStackProtocolImplementation implements if (_publishLockRefusal) { throw withPendingAudit(_publishLockRefusal.err, _publishLockRefusal.audit); } + if (addressRefusal) throw addressRefusal; const draftForGate = await repo.get( { type: singularType, name: request.name, org: 'env' } as Parameters[0], - { state: 'draft', ...(draftKey !== undefined ? { packageId: draftKey } : {}), ...memberOption(request.member) }, + { state: 'draft', packageId: draftKey, ...memberOption(request.member) }, ); // [#21470] …and the divergent `name` refusal, on the same body and for // the same reason: a draft stored before `saveMetaItem` judged every @@ -24299,15 +24434,16 @@ export class ObjectStackProtocolImplementation implements if (nameRefusal) throw nameRefusal; } // [ADR-0086 D3] …and a permission-set draft whose body `packageId` - // disagrees with the package it is promoted into: the key resolved - // above, the one `repo.promoteDraft` writes under. A draft stored + // disagrees with the package it is promoted into: the package resolved + // above, the one `repo.promoteDraft` writes under ([#23495] the item's + // package for an adopted unbound draft). A draft stored // before the save door judged the key can carry one, and promoting it // would make it active with an owner nobody reads. Refused with the // save door's judge and envelope, so a batch publish aborts on it as // on the name refusal. See {@link permissionSetOwnerBindingRefusal}. if (draftForGate) { const ownerRefusal = permissionSetOwnerBindingRefusal( - singularType, draftForGate.body, request.name, draftKey ?? null, 'publish', + singularType, draftForGate.body, request.name, promoteInto, 'publish', ); if (ownerRefusal) throw ownerRefusal; } @@ -24423,14 +24559,17 @@ export class ObjectStackProtocolImplementation implements // audit writer. This door's default says what happened without it. message: request.message || 'publish draft', intent, - // [#8907] Spread: `null` is a meaningful scope (the unbound - // row), so the key is ABSENT rather than present-and-undefined - // when there is none. The key the gated draft was read under (see `draftForGate`): - // the stated binding, or the resolved row's own when none was - // stated, so the promotion cannot pick a different package's - // draft than the one judged above. Absent only when no draft - // was found, where the promotion answers `NO_DRAFT` as before. - ...(draftKey !== undefined ? { packageId: draftKey } : {}), + // [#8907] `null` is a meaningful scope (the unbound row). The + // key the gated draft was read under (see `draftForGate`): the + // stated binding, or the resolved draft's when none was stated, + // so the promotion cannot pick a different package's draft than + // the one judged above. [#23495] Always stated now: with no + // draft at the resolved address the promotion answers + // `NO_DRAFT` at that address, never another package's draft. + packageId: draftKey, + // [#23495] …and an unbound draft of an item bound to a package + // is promoted into that package, as its draft save adopts it. + ...(draftKey === null && promoteInto !== null ? { adoptInto: promoteInto } : {}), // [#21934] …and the promotion writes only the draft the gate // judged: the judged draft's hash (`null` when the gate found // none), so a draft saved after the gate's read is refused as a @@ -28952,6 +29091,9 @@ export class ObjectStackProtocolImplementation implements * item's rows at its state are bound to, is refused `409 * RESOURCE_CONFLICT` when they are bound to two or more, and with none * removes the package-unbound row ({@link packagelessAddress}). + * [#23495] A draft discard naming none removes the draft a draft save + * naming none writes ({@link packagelessDraftAddress}), the one the + * per-item publish naming none promotes. */ packageId?: string | null; }): Promise<{ @@ -29156,22 +29298,43 @@ export class ObjectStackProtocolImplementation implements // reads the draft rows, so it removes the draft that exists, // wherever the draft save put it (#11087). // + // [#23495] …except that is not where the draft save puts it: a + // draft save naming no package writes the draft of the package + // the item's ACTIVE rows are bound to, so over an item bound to + // `P` whose only draft is another package's, the discard + // removed that draft while the save, the read and the token + // named `P`'s. A draft discard naming no package takes the + // draft address instead ({@link packagelessDraftAddress}), the + // one the per-item publish takes: refused when the item's + // active rows are bound to two or more packages, as the draft + // save is. + // // Resolved here, below every door that judges the item rather // than its row (the organization refusal, the packaged-base // door, the creatable check and the `_lock` gate), as the save // door orders it. The rows read here are also what the receipt // below tells the truth about: what is still stored, and so // still served, once this row is gone. - const rowsAtAddress = await this.packagelessAddress( + const draftAddress = request.packageId == null && targetState === 'draft' + ? await this.packagelessDraftAddress(singularTypeForRepo, request.name, member) + : undefined; + if (draftAddress?.kind === 'ambiguous') { + addressRefusal = packagelessAddressAmbiguityRefusal( + 'delete', request.type, request.name, draftAddress.candidates, + ); + throw addressRefusal; + } + const rowsAtAddress = draftAddress?.drafts ?? await this.packagelessAddress( singularTypeForRepo, request.name, member, targetState, ); - if (request.packageId == null && rowsAtAddress.candidates.length > 1) { + if (request.packageId == null && draftAddress === undefined && rowsAtAddress.candidates.length > 1) { addressRefusal = packagelessAddressAmbiguityRefusal( 'delete', request.type, request.name, rowsAtAddress.candidates, ); throw addressRefusal; } - const packageId: string | null = request.packageId ?? rowsAtAddress.packageId; + const packageId: string | null = request.packageId + ?? (draftAddress !== undefined ? draftAddress.key : rowsAtAddress.packageId); // Probe first — "no overlay exists" is a success/no-op, not // a conflict. The repo would otherwise throw ConflictError. const current = await repo.get(ref, { state: targetState, packageId, ...memberOption(member) }); @@ -29196,6 +29359,18 @@ export class ObjectStackProtocolImplementation implements : `No row of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was deleted: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'row')}.`, }; } + // [#23495] …and a draft discard naming no package, of an item + // bound to a package that holds no draft at that package's + // address while other packages' drafts are stored: nothing is + // discarded, and the receipt names what remains rather than + // claiming no draft exists. + if (!current && draftAddress?.kind === 'none' && draftAddress.into !== null && rowsAtAddress.candidates.length > 0) { + return { + success: true, + reset: false, + message: `No pending draft of ${request.type}/${request.name} is bound to package '${draftAddress.into}', the package its published rows are bound to and the draft a discard that names no package removes, so nothing was discarded: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'draft')}.`, + }; + } if (!current) { // Self-heal: even with no overlay row, a stale runtime // shadow may linger in the registry (e.g. pollution from diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index ad9ce06f167..2a8558481c8 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -532,7 +532,10 @@ export function packageScopedRowWhere( * `protocol.ts`), and [#23297] the draft save, whose address * {@link SysMetadataRepository.put} resolves (the inheritance in * `resolveWriteHead`). One sentence for the three, so the doors cannot answer - * the same request in different words. + * the same request in different words. [#23495] And the per-item draft + * publish (`publish`) and the draft discard (`delete`) that name no package, + * whose draft address the protocol resolves as the draft save's + * (`ObjectStackProtocolImplementation.packagelessDraftAddress`). * * A `sys_metadata` row is keyed `(org, type, name, package_id)`, so such a * write has no one row to write or remove. Before this refusal the save wrote @@ -559,7 +562,7 @@ export function packageScopedRowWhere( * predicate. */ export function packagelessAddressAmbiguityRefusal( - verb: 'save' | 'delete', + verb: 'save' | 'delete' | 'publish', type: string, name: string, packageIds: readonly string[], @@ -568,7 +571,9 @@ export function packagelessAddressAmbiguityRefusal( const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; const [nothing, single] = verb === 'save' ? ['Nothing was written.', 'no single row to write'] - : ['Nothing was deleted.', 'no single row to remove']; + : verb === 'publish' + ? ['Nothing was published.', 'no single draft to promote'] + : ['Nothing was deleted.', 'no single row to remove']; const err = new Error( `Name the package this ${verb} is for, then send it again: on the REST door add ?package= with the package id, ` + `in process pass packageId. ${nothing} ${type} '${name}' belongs to ${packageIds.length} packages ` @@ -1277,6 +1282,25 @@ export class SysMetadataRepository implements MetadataRepository { * answering `success: true`. Which row won was driver-order dependent. */ packageId?: string | null; + /** + * [#23495, #11087] The package a package-UNBOUND draft is promoted + * into: the package its draft save naming no package would adopt it + * into (the orphan-draft adoption in {@link resolveWriteHead}, which + * stamps an unbound draft with the package of the item's active rows + * on its next write). Stated by the per-item publish that names no + * package, for an item whose active rows are bound to one package and + * whose only draft at that address is the unbound one: the read naming + * no package serves that draft, and the active row it becomes is that + * package's row, the one the read serves next. Promoted unbound, it + * landed in a package-less active row beside the package's, which no + * read naming no package serves. + * + * Only an unbound draft row is adopted: a draft row bound to a package + * is promoted under its own binding whatever this says, and the drain + * removes the draft row by its own key either way. Omitted → the draft + * row's own binding, as before. + */ + adoptInto?: string; /** * [#20312] The body the ACTIVE row stores, derived from the draft body * this promotion read — applied to the row actually promoted, inside the @@ -1353,6 +1377,9 @@ export class SysMetadataRepository implements MetadataRepository { throw err; } const draftPackageId = (draftRow as { package_id?: string | null }).package_id ?? null; + // [#23495] The binding the ACTIVE row is written under: the draft row's + // own, else the package an unbound draft is adopted into (`adoptInto`). + const activePackageId: string | null = draftPackageId ?? opts.adoptInto ?? null; const draft = this.rowToItem(ref, draftRow); // [#21934] The draft the caller judged, and no other: a caller that read // the draft to judge it states that draft's hash, and the row read here @@ -1363,7 +1390,9 @@ 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 }); + // [#23495] The scope written is `activePackageId`: the draft's own binding, + // or the package an unbound draft is adopted into. + const currentActive = await this.get(ref, { state: 'active', packageId: activePackageId, member: opts.member }); const activeBody = opts.deriveActiveBody ? opts.deriveActiveBody(draft.body) : draft.body; const result = await this.put(ref, activeBody, { parentVersion: currentActive?.hash ?? null, @@ -1373,7 +1402,7 @@ export class SysMetadataRepository implements MetadataRepository { intent: opts.intent ?? 'override-artifact', state: 'active', opType: 'publish', - packageId: draftPackageId, + packageId: activePackageId, }); // Drop the draft row — it has been promoted. // @@ -1394,10 +1423,12 @@ export class SysMetadataRepository implements MetadataRepository { } // Surface the promoted draft's package binding so publish-time // materializers (ADR-0086 P2 — package-door permission sets) can stamp - // the data-plane row with the owning `package_id`. + // the data-plane row with the owning `package_id`. [#23495] The binding + // the active row was written under, which is the adopting package for an + // adopted unbound draft. return { ...result, - packageId: draftPackageId, + packageId: activePackageId, ...(draftDrainFailed ? { draftDrainFailed } : {}), }; } From 13d312473d8ace5701ebb4a08029bf353744a1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:59:37 +0000 Subject: [PATCH 2/5] test(metadata-protocol,rest): the draft doors naming no package act on the draft the draft save writes Pins both card states in both storage orders, the package-unbound draft promoted into the item's package, two packages refused 409, an item never published into a package, the draft-save receipt, and the repository's adoptInto. Two pins of the removed first-row pick are replaced. Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- ...tocol-publish-drafts-package-scope.test.ts | 41 ++- .../protocol.org-scoped-write-refused.test.ts | 20 +- .../protocol.packageless-save-address.test.ts | 277 ++++++++++++++++++ ...a-repository.draft-package-inherit.test.ts | 45 +++ ...eta-draft-head-package-inheritance.test.ts | 106 ++++++- 5 files changed, 475 insertions(+), 14 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts index c4a98fb04c8..3eff1b15759 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts @@ -373,24 +373,49 @@ describe('publishMetaItem — the per-item door names a package too (#10350)', ( expect(labelOf(drafts[0])).toBe('FROM_OTHER'); }); - it('keeps the historical match-any resolution when the caller states NO package', async () => { + // [#23495] Replaced, not respelled: this case pinned the absent key's + // "match any package" resolution, which promoted this fixture's + // first-scanned draft (`app.other`) — the storage-order coin toss #8907 + // removed from the batch door, on the per-item door. Naming no package, + // the publish now promotes the draft a draft save naming no package + // writes, and this item (no active row, two packages' drafts, no + // package-less draft) has no single one. What the case also guarded stays + // pinned: the absent key is not a present `null`, which would pin the + // lookup to the unbound row and answer `no_draft` (the next case). + it('refuses 409 RESOURCE_CONFLICT when the caller states NO package and two packages hold drafts, promoting nothing', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await seedTwoPackageDrafts(protocol); + const refusal = await protocol.publishMetaItem({ type: 'object', name: 'shared_ticket' }) + .then(() => undefined, (e: any) => e); + + expect(refusal).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 }); + expect(refusal.message).toContain("'app.demo'"); + expect(refusal.message).toContain("'app.other'"); + expect(activeRowsOf(rows)).toEqual([]); + expect(draftRowsOf(rows).map((r) => r.package_id)).toEqual(['app.other', 'app.demo']); + }); + + it('promotes the one package\'s draft when the caller states NO package — the absent key is not a present `null`', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'shared_ticket', + item: objectBody('shared_ticket', 'FROM_OTHER'), + packageId: 'app.other', + mode: 'draft', + }); + await protocol.publishMetaItem({ type: 'object', name: 'shared_ticket' }); - // The contract `promoteDraftForPublish` spells as - // `...('packageId' in request ? ... : {})`: an ABSENT key means "match - // any package" (this fixture's first-scanned row), while a - // present-and-`undefined` key would coerce to `null` downstream and pin - // the lookup to UNBOUND rows — finding neither draft and answering - // `no_draft`. Declaring `packageId` optional must not turn the first - // spelling into the second, so the untouched path is pinned here. const active = activeRowsOf(rows); expect(active).toHaveLength(1); expect(active[0].package_id).toBe('app.other'); expect(labelOf(active[0])).toBe('FROM_OTHER'); + expect(draftRowsOf(rows)).toEqual([]); }); }); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 84be541fe6f..966ff64d9af 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -523,9 +523,9 @@ describe('a publish promotes only the draft its gate judged', () => { // The lock a publish consults is the one at the address it promotes: the // package key the publish resolved (the caller's stated binding, else the -// draft row's own), not only the package the request stated. With two -// packages' env-wide rows of one view both locked, the refusal carries the -// lock of the package whose draft is being promoted. +// draft address it resolves), not only the package the request stated. With +// two packages' env-wide rows of one view both locked, the refusal of a +// publish naming its package carries that package's lock. describe('a publish consults the lock of the package key it resolved', () => { const locked = (pkg: string) => ({ ...VIEW, _lock: 'full', _lockReason: `${pkg} keeps this view as shipped` }); @@ -543,10 +543,20 @@ describe('a publish consults the lock of the package key it resolved', () => { return { protocol, draftsOf }; } - it('a publish that states no package consults the lock of the draft row\'s own package', async () => { + // [#23495] Replaced, not respelled: this case pinned a publish naming no + // package consulting the lock of the draft row a package-agnostic read met + // (`pkg_b`), over an item whose active rows two packages hold. Naming no + // package, the publish now takes the draft save's address, and an item two + // packages hold has none: the publish is judged by the lock the draft save + // naming no package is judged by, and answers what that save answers. + it('a publish that states no package, of an item two packages hold, is judged by the lock the draft save naming none is', async () => { const { protocol, draftsOf } = await seedLockedRowsAndDraft('pkg_b'); + const saveRefusal = await protocol.saveMetaItem({ + type: 'view', name: VIEW.name, item: { ...VIEW, label: 'Org grid (edited)' }, mode: 'draft', + }).then(() => undefined, (e: any) => e); + expect(saveRefusal).toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full' }); await expect(protocol.publishMetaItem({ type: 'view', name: VIEW.name })) - .rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full', lockReason: 'pkg_b keeps this view as shipped' }); + .rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, lock: 'full', lockReason: saveRefusal.lockReason }); expect(draftsOf()).toEqual(['pkg_b']); }); 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 f48c38f9720..079d0ef9e6d 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -46,6 +46,12 @@ * 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. + * - [#23495] the per-item publish and the draft discard naming no package act + * on the draft the draft save naming none writes: the card's two states in + * both storage orders, the package-unbound draft of a package's item + * (promoted into the package), two packages (refused), and an item never + * published into a package; the draft save's receipt names the package it + * inherits. * * Refusals are asserted as the envelope an HTTP boundary answers with * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. @@ -959,3 +965,274 @@ describe('[#23291] an object\'s destructive-change check judges the row the save expect(secondRows('object', `${name}_2`).map((r) => [r.package_id ?? null, fieldsOf(r)])).toEqual(secondBefore); }); }); + +/** + * [#23495, ADR-0048, ADR-0070 TL;DR 1] The draft doors that name no package + * act on ONE draft: the one the draft save naming no package writes. + * + * The draft save inherits the package of the item's active rows (#11087), and + * since #23291 the strict draft read and the preview follow it. The per-item + * publish took the first draft row a package-agnostic `findOne` met, and the + * draft discard read its address from the draft rows alone. Measured on a real + * stack (better-sqlite3, the real routes) before the fix, over an item whose + * active rows are bound to `P`: + * + * - drafts `[package-unbound, P]`: the save, the read and the discard named + * `P`'s draft; the publish promoted the package-unbound draft into a + * package-less active row, which no read naming no package serves; + * - the only draft bound to `Q`: the read answered `404 NO_DRAFT` and the + * save wrote `P`'s draft; the publish promoted `Q`'s draft into a second + * package's active row, and the discard removed it. + * + * Every door below is driven on its own fixture, built through the save door, + * in both storage orders where a package-agnostic read could tell them apart, + * and the draft each one acts on is compared with the draft the save writes. + */ +describe('[#23495] the draft doors naming no package act on the draft the draft save writes', () => { + const P = WRITABLE; + const Q = OTHER_WRITABLE; + const R = 'com.qa.thirdapp'; + type Fixture = ReturnType & { name: string }; + + /** `label@binding` of every row of the item at one state, in storage order. */ + const stored = (f: Fixture, state: 'active' | 'draft') => + f.rows('app', f.name, state).map((r) => `${JSON.parse(r.metadata).label}@${r.package_id ?? 'null'}`); + + const saveDraft = (f: Fixture, label: string) => + f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label }), mode: 'draft' }); + const publish = (f: Fixture) => f.protocol.publishMetaItem({ type: 'app', name: f.name }); + const discard = (f: Fixture) => f.protocol.deleteMetaItem({ type: 'app', name: f.name, state: 'draft' }); + + /** The binding of the draft row that changed between two readings: the draft a door acted on. */ + const actedOn = (before: string[], after: string[]): string[] => + [...before.filter((r) => !after.includes(r)), ...after.filter((r) => !before.includes(r))] + .map((r) => r.slice(r.lastIndexOf('@') + 1)) + .filter((binding, i, all) => all.indexOf(binding) === i); + + /** + * Drafts `[package-unbound, P]` over an active row bound to `P`, reached + * through the save door in either storage order: a package-less draft + * saved before the item had an active row stays package-unbound. + */ + async function stateOne(name: string, unboundFirst: boolean): Promise { + const f = { ...makeProtocol(), name }; + if (unboundFirst) { + await saveDraft(f, 'stale'); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live' }), packageId: P }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pending' }), packageId: P, mode: 'draft' }); + } else { + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pending' }), packageId: P, mode: 'draft' }); + await saveDraft(f, 'stale'); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live' }), packageId: P }); + } + return f; + } + + /** The active row bound to `P`, the only draft bound to `Q` (reached through `?mode=draft&package=Q`). */ + async function stateTwo(name: string): Promise { + const f = { ...makeProtocol(), name }; + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live' }), packageId: P }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: Q, mode: 'draft' }); + return f; + } + + for (const unboundFirst of [true, false]) { + const order = unboundFirst ? 'the package-unbound draft stored first' : 'P\'s draft stored first'; + + it(`drafts [package-unbound, P] over P (${order}): the read, the publish and the discard act on the draft the save writes`, async () => { + const name = `s1_${unboundFirst ? 'unbound_first' : 'pkg_first'}`; + const precondition = await stateOne(name, unboundFirst); + expect(stored(precondition, 'draft')).toEqual(unboundFirst ? ['stale@null', `pending@${P}`] : [`pending@${P}`, 'stale@null']); + expect(stored(precondition, 'active')).toEqual([`live@${P}`]); + + // The reference: the draft the save naming no package writes, and its receipt. + const s = await stateOne(name, unboundFirst); + const before = stored(s, 'draft'); + const saved = await saveDraft(s, 'saved'); + const saveAddress = actedOn(before, stored(s, 'draft')); + expect(saveAddress).toEqual([P]); + expect(saved.message).toBe(`Saved app '${name}' (env-wide, package=${P}, state=draft) [seq=${saved.seq}]`); + + // The strict draft read serves it. + const r = await stateOne(name, unboundFirst); + expect((await r.protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('pending'); + + // The publish promotes it into P's active row, and the package-unbound draft stays. + const p = await stateOne(name, unboundFirst); + const published = await publish(p); + expect(published.success).toBe(true); + expect(actedOn(before, stored(p, 'draft'))).toEqual(saveAddress); + expect(stored(p, 'active')).toEqual([`pending@${P}`]); + expect(stored(p, 'draft')).toEqual(['stale@null']); + expect((await p.protocol.getMetaItem({ type: 'app', name })).item.label).toBe('pending'); + + // The discard removes it, and the package-unbound draft stays. + const d = await stateOne(name, unboundFirst); + expect((await discard(d)).reset).toBe(true); + expect(actedOn(before, stored(d, 'draft'))).toEqual(saveAddress); + expect(stored(d, 'draft')).toEqual(['stale@null']); + }); + } + + it('the active row bound to P, the only draft bound to Q: the publish answers 404 NO_DRAFT and the discard removes nothing, as the read finds no draft', async () => { + const name = 's2_other_pkg_draft'; + + // The reference: the save writes P's draft, beside Q's. + const s = await stateTwo(name); + await saveDraft(s, 'saved'); + expect(stored(s, 'draft')).toEqual(['other@' + Q, `saved@${P}`]); + + // The read answers 404 NO_DRAFT (#23291). + const r = await stateTwo(name); + expect(resolveThrownHttpError(await refusalOf(() => r.protocol.getMetaItem({ type: 'app', name, state: 'draft' })))) + .toMatchObject({ status: 404, code: 'NO_DRAFT' }); + + // The publish answers the same, names Q's draft and the request that reaches it, and writes nothing. + const p = await stateTwo(name); + const err = await refusalOf(() => publish(p)); + expect(resolveThrownHttpError(err)).toMatchObject({ status: 404, code: 'NO_DRAFT' }); + expect(err.message).toContain(`'${Q}'`); + expect(err.message).toContain('?package='); + expect(stored(p, 'active')).toEqual([`live@${P}`]); + expect(stored(p, 'draft')).toEqual([`other@${Q}`]); + expect(p.metadataWrites().filter((w) => w.op === 'insert' || w.op === 'update')).toHaveLength(2); + + // The discard removes nothing and says Q's draft remains. + const d = await stateTwo(name); + const receipt = await discard(d); + expect(receipt).toMatchObject({ success: true, reset: false }); + expect(receipt.message).toContain(`'${Q}'`); + expect(stored(d, 'draft')).toEqual([`other@${Q}`]); + + // CONTROL: naming Q reaches Q's draft on both doors. + const pq = await stateTwo(name); + await pq.protocol.publishMetaItem({ type: 'app', name, packageId: Q }); + expect(stored(pq, 'active')).toEqual([`live@${P}`, `other@${Q}`]); + const dq = await stateTwo(name); + expect((await dq.protocol.deleteMetaItem({ type: 'app', name, state: 'draft', packageId: Q })).reset).toBe(true); + expect(stored(dq, 'draft')).toEqual([]); + }); + + it('the only draft of an item bound to P is package-unbound: the publish promotes it INTO P\'s active row, as the save adopts it', async () => { + const name = 's3_unbound_draft'; + const build = async (): Promise => { + const f = { ...makeProtocol(), name }; + await saveDraft(f, 'stale'); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live' }), packageId: P }); + return f; + }; + + // The reference: the save writes that draft and adopts it into P; the read serves it. + const s = await build(); + await saveDraft(s, 'saved'); + expect(stored(s, 'draft')).toEqual([`saved@${P}`]); + const r = await build(); + expect((await r.protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('stale'); + + // The publish promotes it into P's active row, the row the read naming no package serves next. + const p = await build(); + await publish(p); + expect(stored(p, 'active')).toEqual([`stale@${P}`]); + expect(stored(p, 'draft')).toEqual([]); + expect((await p.protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ label: 'stale', _packageId: P }); + + // The discard removes it. + const d = await build(); + expect((await discard(d)).reset).toBe(true); + expect(stored(d, 'draft')).toEqual([]); + }); + + it('the active rows bound to two packages: the publish and the discard are refused 409 RESOURCE_CONFLICT, as the draft save is, and change nothing', async () => { + const name = 's7_two_packages'; + const build = async (): Promise => { + const f = { ...makeProtocol(), name }; + 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: 'pending' }), packageId: P, mode: 'draft' }); + return f; + }; + + const s = await build(); + expect(resolveThrownHttpError(await refusalOf(() => saveDraft(s, 'saved')))).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + + for (const [door, run] of [['publish', publish], ['discard', discard]] as const) { + const f = await build(); + const err = await refusalOf(() => run(f)); + expect(resolveThrownHttpError(err), door).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + expect(err.message, door).toContain(`'${P}'`); + expect(err.message, door).toContain(`'${Q}'`); + expect(err.message, door).toContain('?package='); + expect(stored(f, 'active'), door).toEqual([`live P@${P}`, `live Q@${Q}`]); + expect(stored(f, 'draft'), door).toEqual([`pending@${P}`]); + } + + // Naming the package publishes its draft. + const named = await build(); + await named.protocol.publishMetaItem({ type: 'app', name, packageId: P }); + expect(stored(named, 'active')).toEqual([`pending@${P}`, `live Q@${Q}`]); + }); + + describe('an item never published into a package: the package-unbound draft the save writes, else the one package\'s draft', () => { + for (const unboundFirst of [true, false]) { + it(`drafts [package-unbound, Q], ${unboundFirst ? 'the package-unbound one' : 'Q\'s'} stored first: both doors act on the package-unbound draft`, async () => { + const name = `s4_${unboundFirst ? 'unbound_first' : 'pkg_first'}`; + const build = async (): Promise => { + const f = { ...makeProtocol(), name }; + if (unboundFirst) await saveDraft(f, 'unbound'); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: Q, mode: 'draft' }); + if (!unboundFirst) await saveDraft(f, 'unbound'); + return f; + }; + + const s = await build(); + const before = stored(s, 'draft'); + const saved = await saveDraft(s, 'saved'); + expect(actedOn(before, stored(s, 'draft'))).toEqual(['null']); + // CONTROL: no package to inherit, so the receipt reads as before. + expect(saved.message).toBe(`Saved app '${name}' (env-wide, state=draft) [seq=${saved.seq}]`); + + const p = await build(); + await publish(p); + expect(stored(p, 'active')).toEqual(['unbound@null']); + expect(stored(p, 'draft')).toEqual([`other@${Q}`]); + + const d = await build(); + await discard(d); + expect(stored(d, 'draft')).toEqual([`other@${Q}`]); + }); + } + + it('only Q\'s draft: both doors act on it (the draft the read naming no package serves)', async () => { + const name = 's6_only_pkg_draft'; + const build = async (): Promise => { + const f = { ...makeProtocol(), name }; + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: Q, mode: 'draft' }); + return f; + }; + const r = await build(); + expect((await r.protocol.getMetaItem({ type: 'app', name, state: 'draft' })).item.label).toBe('other'); + const p = await build(); + await publish(p); + expect(stored(p, 'active')).toEqual([`other@${Q}`]); + const d = await build(); + expect((await discard(d)).reset).toBe(true); + expect(stored(d, 'draft')).toEqual([]); + }); + + it('drafts bound to two packages and none package-unbound: both doors are refused 409 RESOURCE_CONFLICT', async () => { + const name = 's8_two_pkg_drafts'; + const build = async (): Promise => { + const f = { ...makeProtocol(), name }; + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: Q, mode: 'draft' }); + await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'third' }), packageId: R, mode: 'draft' }); + return f; + }; + for (const [door, run] of [['publish', publish], ['discard', discard]] as const) { + const f = await build(); + expect(resolveThrownHttpError(await refusalOf(() => run(f))), door).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + expect(stored(f, 'active'), door).toEqual([]); + expect(stored(f, 'draft'), door).toEqual([`other@${Q}`, `third@${R}`]); + } + }); + }); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts index 96a4c2c11ef..5b64c592d68 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts @@ -288,3 +288,48 @@ describe('[#23297] SysMetadataRepository draft inheritance reads every active ro }); } }); + +/** + * [#23495] `promoteDraft`'s `adoptInto`: the package a package-UNBOUND draft is + * promoted into, the package its draft save naming no package would adopt it + * into (the orphan adoption above). Stated by the per-item publish naming no + * package, for an item bound to one package whose only draft at that address is + * the unbound one. A draft bound to a package keeps its own binding. + */ +describe('[#23495] SysMetadataRepository.promoteDraft adoptInto', () => { + const active = (pkg: string | null, label: string) => ({ + type: REF.type, name: REF.name, state: 'active', package_id: pkg, + metadata: JSON.stringify({ label }), checksum: `sha-active-${label}`, version: 1, + }); + const draft = (pkg: string | null, label: string) => ({ + type: REF.type, name: REF.name, state: 'draft', package_id: pkg, + metadata: JSON.stringify({ label }), checksum: `sha-draft-${label}`, version: 2, + }); + const activeRows = (engine: ReturnType) => engine.rows + .filter((r) => r.state === 'active') + .map((r) => [r.package_id ?? null, JSON.parse(String(r.metadata)).label]); + + it('an unbound draft is promoted into the package\'s active row, never a package-less one', async () => { + const engine = makeFakeEngine([active('app.k9qk', 'live'), draft(null, 'orphan')]); + const repo = makeRepo(engine); + const result = await repo.promoteDraft(REF, { actor: 't', packageId: null, adoptInto: 'app.k9qk' }); + expect(result.packageId).toBe('app.k9qk'); + expect(activeRows(engine)).toEqual([['app.k9qk', 'orphan']]); + }); + + it('a draft bound to a package keeps its own binding, whatever adoptInto says', async () => { + const engine = makeFakeEngine([active('app.k9qk', 'live'), draft('app.other', 'other')]); + const repo = makeRepo(engine); + const result = await repo.promoteDraft(REF, { actor: 't', packageId: 'app.other', adoptInto: 'app.k9qk' }); + expect(result.packageId).toBe('app.other'); + expect(activeRows(engine)).toEqual([['app.k9qk', 'live'], ['app.other', 'other']]); + }); + + it('control: omitted, an unbound draft is promoted package-unbound, as before', async () => { + const engine = makeFakeEngine([active('app.k9qk', 'live'), draft(null, 'orphan')]); + const repo = makeRepo(engine); + const result = await repo.promoteDraft(REF, { actor: 't', packageId: null }); + expect(result.packageId).toBeNull(); + expect(activeRows(engine)).toEqual([['app.k9qk', 'live'], [null, 'orphan']]); + }); +}); 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 ccdb64aa5b9..873490b044c 100644 --- a/packages/rest/src/meta-draft-head-package-inheritance.test.ts +++ b/packages/rest/src/meta-draft-head-package-inheritance.test.ts @@ -171,7 +171,11 @@ async function boot(opts: { item?: { type: string; name: string }; permissions?: const remove = (query: Record = {}, headers: Record = {}) => call('DELETE', `${META}/:type/:name`, { path, params: item, query, headers }); - return { read, save, remove, stored, storedBodies, seed }; + /** `POST /meta/view/case_grid/publish` (the per-item draft promotion). */ + const publish = (query: Record = {}) => + call('POST', `${META}/:type/:name/publish`, { path: `${path}/publish`, params: item, query, body: {} }); + + return { read, save, remove, publish, stored, storedBodies, seed }; } const ok = (res: any) => { @@ -420,3 +424,103 @@ describe('[#23165] a save or a delete naming no package addresses the one packag expect(await h.stored('active')).toEqual([]); }, 60_000); }); + +/** + * [#23495, ADR-0048] The draft doors that name no package, on the real routes: + * the per-item publish (`POST …/publish`) and the draft discard + * (`DELETE …?state=draft`) act on the draft the draft save naming no package + * writes (`PUT …?mode=draft`), which the strict draft read serves. Measured on + * these routes before the fix: over an item bound to a package, the publish + * promoted the package-unbound draft beside the package's (the store returns + * it first) into a package-less active row, and with the only draft bound to + * another package the publish promoted it into a second package's active row + * and the discard removed it, while the read answered `404 NO_DRAFT`. The + * protocol suite (`protocol.packageless-save-address.test.ts` in + * metadata-protocol) holds the rest of the matrix, both storage orders + * included, over a stub engine. + */ +describe('[#23495] the draft doors naming no package act on the draft the draft save writes, on the real routes', () => { + const OTHER_PKG = 'com.probe.other'; + + /** Drafts `[package-unbound, PKG]` over an active row bound to `PKG`. */ + const stateOne = async () => { + const h = await boot(); + ok(await h.save(view('stale'), { mode: 'draft' })); + ok(await h.save(view('live'), { package: PKG })); + ok(await h.save(view('pending'), { mode: 'draft', package: PKG })); + return h; + }; + /** The active row bound to `PKG`, the only draft bound to `OTHER_PKG`. */ + const stateTwo = async () => { + const h = await boot(); + ok(await h.save(view('live'), { package: PKG })); + ok(await h.save(view('other'), { mode: 'draft', package: OTHER_PKG })); + return h; + }; + + it('drafts [package-unbound, P] over P: the save, the read, the publish and the discard all address P\'s draft', async () => { + const s = await stateOne(); + const saved = ok(await s.save(view('saved'), { mode: 'draft' })); + expect(saved.message).toContain(`package=${PKG}`); + expect(await s.stored('draft')).toEqual(expect.arrayContaining([{ label: 'saved', packageId: PKG }, { label: 'stale', packageId: null }])); + + const r = await stateOne(); + expect(ok(await r.read({ state: 'draft' })).item.label).toBe('pending'); + + const p = await stateOne(); + ok(await p.publish()); + expect(await p.stored('active')).toEqual([{ label: 'pending', packageId: PKG }]); + expect(await p.stored('draft')).toEqual([{ label: 'stale', packageId: null }]); + expect(ok(await p.read()).item.label).toBe('pending'); + + const d = await stateOne(); + expect(ok(await d.remove({ state: 'draft' })).reset).toBe(true); + expect(await d.stored('draft')).toEqual([{ label: 'stale', packageId: null }]); + }, 60_000); + + it('the only draft bound to another package: the publish answers 404 NO_DRAFT as the read does, the discard removes nothing, and ?package= reaches it', async () => { + const r = await stateTwo(); + const read = await r.read({ state: 'draft' }); + expect(read.statusCode).toBe(404); + expect(read.body.code).toBe('NO_DRAFT'); + + const p = await stateTwo(); + const published = await p.publish(); + expect(published.statusCode, JSON.stringify(published.body)).toBe(404); + expect(published.body.code).toBe('NO_DRAFT'); + expect(published.body.error).toContain(OTHER_PKG); + expect(await p.stored('active')).toEqual([{ label: 'live', packageId: PKG }]); + expect(await p.stored('draft')).toEqual([{ label: 'other', packageId: OTHER_PKG }]); + + const d = await stateTwo(); + const discarded = ok(await d.remove({ state: 'draft' })); + expect(discarded.reset).toBe(false); + expect(discarded.message).toContain(OTHER_PKG); + expect(await d.stored('draft')).toEqual([{ label: 'other', packageId: OTHER_PKG }]); + + const named = await stateTwo(); + ok(await named.publish({ package: OTHER_PKG })); + expect(await named.stored('draft')).toEqual([]); + expect(await named.stored('active')).toEqual(expect.arrayContaining([{ label: 'other', packageId: OTHER_PKG }])); + }, 60_000); + + it('active rows bound to two packages: the publish and the discard naming none are refused 409 RESOURCE_CONFLICT, as the draft save is', async () => { + const build = async () => { + const h = await boot(); + ok(await h.save(view('A'), { package: PKG })); + ok(await h.save(view('B'), { package: OTHER_PKG })); + ok(await h.save(view('pending'), { mode: 'draft', package: PKG })); + return h; + }; + for (const door of ['save', 'publish', 'discard'] as const) { + const h = await build(); + const res = door === 'save' + ? await h.save(view('saved'), { mode: 'draft' }) + : door === 'publish' ? await h.publish() : await h.remove({ state: 'draft' }); + expect(res.statusCode, `${door}: ${JSON.stringify(res.body)}`).toBe(409); + expect(res.body.code, door).toBe('RESOURCE_CONFLICT'); + expect(await h.stored('draft'), door).toEqual([{ label: 'pending', packageId: PKG }]); + expect(await h.stored('active'), door).toHaveLength(2); + } + }, 60_000); +}); From 740f97bcb70ec5ac31b77658cd0cd791b47a990f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:10:38 +0000 Subject: [PATCH 3/5] fix(spec): the publish and delete request contracts state what a draft door naming no package acts on; changeset Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- .changeset/23495-draft-doors-one-address.md | 36 +++++++++++++++++++++ content/docs/references/api/protocol.mdx | 4 +-- packages/spec/src/api/protocol.zod.ts | 34 ++++++++++++------- 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .changeset/23495-draft-doors-one-address.md diff --git a/.changeset/23495-draft-doors-one-address.md b/.changeset/23495-draft-doors-one-address.md new file mode 100644 index 00000000000..463b63f7413 --- /dev/null +++ b/.changeset/23495-draft-doors-one-address.md @@ -0,0 +1,36 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +--- + +fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a draft per package. A draft save that names no package (`PUT /api/v1/meta/TYPE/NAME?mode=draft`) writes the draft of the package the item's active rows are bound to, and the strict draft read and the draft preview that name no package serve that draft. The other two draft doors did not take that address. + +**FROM.** Over an item whose active rows are bound to package `P`: + +- With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged. +- With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft. +- With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves. + +Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package. + +**TO.** A per-item publish and a draft discard that name no package act on the draft the draft save naming no package writes: + +- **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain. +- **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`. +- **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft; when two or more packages hold drafts, both are refused `409 RESOURCE_CONFLICT`. + +The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before. + +**Unchanged.** A publish or a discard that names its package (`?package=`, `packageId` in process). A publish stating `packageId: null` in process, which still pins the package-unbound draft. The batch publish and discard of a package's drafts (`POST /packages/:id/publish-drafts`, the discard of a package's drafts). No stored row is rewritten or removed by the upgrade. + +**Remedy.** For an item two or more packages hold, or to publish or discard a draft bound to a package other than the item's own, send the request with `?package=` naming that package (`packageId` in process). A client that compares draft-save receipt text should expect `, package=` after `env-wide` when the draft inherits a package. + +`@objectstack/spec`: the `PublishMetaItemRequestSchema.packageId` description now states what a publish naming no package promotes, where it said "match any package"; the `DeleteMetaItemRequestSchema.packageId` description states what a draft discard naming no package removes. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index cacc34f5098..d3da4d613ee 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -729,7 +729,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. | | **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. | | **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. | -| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete then removes the row of the one package the item's rows at the deleted `state` are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete of the `active` row then removes the row of the one package the item's active rows are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp); the discard of the `draft` removes the draft a draft save naming no package writes, the one the per-item publish naming none promotes, and is refused where that publish is (see `PublishMetaItemRequestSchema.packageId`). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. | --- @@ -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). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `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` on the untouched path. | +| **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`. | | **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. | diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index c20444ce449..166ef804dff 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1038,14 +1038,20 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ packageId: z.string().nullable().optional().describe( 'ADR-0048 — the software package the draft being promoted was listed ' + 'under, when the caller has one to state (`?package=` on the REST ' - + 'door). ⚠️ `null` is NOT the same as absent, and the ' - + 'difference is load-bearing: the implementation branches on the KEY ' - + 'BEING PRESENT, so an ABSENT key keeps the historical "match any ' - + 'package" resolution while `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` on the untouched ' - + 'path.', + + '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`.', ), member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), })); @@ -1517,11 +1523,15 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ + '`all` and an empty value name no package), as a save names the row it ' + 'writes. Named, the delete removes that package\'s row and no other; a ' + 'package that holds no row removes nothing. Absent or `null` names no ' - + 'package: the delete then removes the row of the one package the ' - + 'item\'s rows at the deleted `state` are bound to, is refused `409 ' + + 'package: the delete of the `active` row then removes the row of the ' + + 'one package the item\'s active rows are bound to, is refused `409 ' + 'RESOURCE_CONFLICT` when they are bound to two or more, and with none ' - + 'removes the env-local row (no package stamp). That row is the one ' - + '`parentVersion` is judged against. When another row of the item is ' + + 'removes the env-local row (no package stamp); the discard of the ' + + '`draft` removes the draft a draft save naming no package writes, the ' + + 'one the per-item publish naming none promotes, and is refused where ' + + 'that publish is (see `PublishMetaItemRequestSchema.packageId`). That ' + + 'row is the one `parentVersion` is judged against. When another row of ' + + 'the item is ' + 'still stored afterwards, the receipt `message` names it.', ), })); From c313d36d74b7060818a5594723d1e296ec68fd43 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:30:56 +0000 Subject: [PATCH 4/5] test(objectql): the seed self-apply publish double answers engine.find, which the draft address reads Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- .../src/protocol-publish-package-drafts.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index 82ca8ca600d..0646a4ed615 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -452,11 +452,13 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => { */ describe('protocol.publishMetaItem — seed self-apply', () => { function makePublishable(body: unknown) { - // A publish that states no package reads the pending draft row's package - // binding once (`engine.findOne` on `sys_metadata`) so the gate and the - // promotion resolve one draft (ADR-0048). No row here: the binding stays - // unstated and the repository double below answers both reads. - const protocol = new ObjectStackProtocolImplementation({ findOne: async () => null } as never); + // A publish that states no package reads the item's active and draft + // rows once (`engine.find` on `sys_metadata`) to resolve the draft a + // draft save naming no package writes, so the gate and the promotion + // resolve one draft (ADR-0048, #23495). No row here: the address is the + // package-unbound draft, and the repository double below answers both + // reads. + const protocol = new ObjectStackProtocolImplementation({ findOne: async () => null, find: async () => [] } as never); (protocol as any).ensureOverlayIndex = async () => {}; // [#21694] No lock on these items. Stubbed at `lockWriteRefusal`, the // verdict the publish path asks (`promoteDraftForPublish`, since #8594), From fd2cd0764a5257fb53234d54107ade337e1ae71c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:51:50 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs(changeset):=20the=20draft-doors=20chan?= =?UTF-8?q?geset=20declares=20Clause-=E2=91=A1=20yes=20(narrowing)=20and?= =?UTF-8?q?=20names=20the=20discard's=20409=20corner=20it=20widens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude --- .changeset/23495-draft-doors-one-address.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/23495-draft-doors-one-address.md b/.changeset/23495-draft-doors-one-address.md index 463b63f7413..f1cfde92ab3 100644 --- a/.changeset/23495-draft-doors-one-address.md +++ b/.changeset/23495-draft-doors-one-address.md @@ -5,7 +5,7 @@ fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070) -Clause-②: no (narrowing) +Clause-②: yes (narrowing) @@ -18,6 +18,7 @@ A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a - With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged. - With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft. - With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves. +- With drafts bound to two or more packages, `DELETE …?state=draft` was refused `409 RESOURCE_CONFLICT`. The discard took its address from the draft rows, not from the active rows the draft save inherits from. An item never published into a package, with a package-unbound draft beside two or more packages' drafts, was refused the same way. Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package. @@ -25,7 +26,7 @@ Over an item whose active rows two packages hold, the publish naming no package - **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain. - **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`. -- **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft; when two or more packages hold drafts, both are refused `409 RESOURCE_CONFLICT`. +- **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before.