From 43e63ab6b777bc4b25c1e04af698eb9abaf2fb6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:57:11 +0000 Subject: [PATCH 01/12] fix(metadata-protocol): a direct save naming no package writes the row of the one package the item is bound to A package-less direct save resolves its write address from the bindings of the item's active rows: one package means that package's row (the save then reads as a save naming it), none keeps the package-unbound row, and several is refused with RESOURCE_CONFLICT naming the candidates. The item read's version token for a package-less active read takes the same resolution, and the receipt names the package the row went to. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 144 +++++++++++++++++- .../src/sys-metadata-repository.ts | 98 +++++++++--- 2 files changed, 218 insertions(+), 24 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e5f31f6faed..b065785f830 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1123,6 +1123,44 @@ function authoredLockFamilyRefusal( return err; } +/** + * [#23165, ADR-0048] The save door's refusal of a direct save that names no + * package, of an item whose active rows are bound to two or more packages. + * + * A `sys_metadata` row is keyed `(org, type, name, package_id)`, so such a + * save has no one row to write. Before this refusal it wrote a package-less + * row beside the packages' rows and answered `200`, for a row the item read + * does not serve. Choosing one of the packages instead would be the + * package-agnostic `findOne`'s driver-order coin toss (#8907). The remedy is + * the request's: name the package, which is the address every other door of + * the item already takes (`?package=`, ADR-0048). + * + * `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member for + * a refusal that comes from environment state rather than the body (the same + * body is accepted once the request names its package), and the code this + * package already answers when a name is held by more than one owner + * (`importUnmanagedPackage`). The prescription first, because a 4xx message + * crosses the REST boundary bounded at 500 characters with its tail + * truncated; the candidates follow, sorted, so the message is the same on + * every driver. + */ +function packagelessSaveAmbiguityRefusal( + type: string, + name: string, + packageIds: readonly string[], +): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } { + const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); + const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; + const err = new Error( + `Name the package this save is for, then send it again: on the REST door add ?package= with the package id, ` + + `in process pass packageId. Nothing was written. ${type} '${name}' belongs to ${packageIds.length} packages ` + + `(${shown}${more}), and a save that names none has no single row to write.`, + ) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 }; + err.code = 'RESOURCE_CONFLICT'; + err.status = 409; + 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. @@ -18498,6 +18536,11 @@ export class ObjectStackProtocolImplementation implements * token of the draft a package-less draft save overwrites, which is the * draft bound to the package of the item's active row when there is one * (#11087) — the address the save resolves, not the key it was handed. + * [#23165] The active read that names no package likewise serves the token + * of the row a direct save naming none writes: the one package's row the + * item is bound to ({@link packagelessSaveAddress}). For an item several + * packages hold, that save is refused whatever it carries, and the token + * stays the package-unbound row's. */ private async readVersionToken( /** [#22877] `member` — the bundle member the read addressed; omitted, the canonical one. */ @@ -18510,10 +18553,51 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: 'env', } as Parameters[0]; - const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null, request.member); + const packageId = request.packageId === undefined && state === 'active' + ? (await this.packagelessSaveAddress(repo, ref, request.member)).packageId + : request.packageId ?? null; + const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); return stored === null ? null : this.receiptVersion(stored); } + /** + * [#23165, ADR-0048] Where a DIRECT (active) save that names no package + * writes: the item's own package when its active rows at the address are + * bound to exactly one, else the package-unbound row. `candidates` is every + * binding ({@link SysMetadataRepository.activeBindingsAt}); two or more is + * a name several packages hold, which {@link saveMetaItem} refuses + * ({@link packagelessSaveAmbiguityRefusal}) and for which `packageId` is + * `null`. + * + * The one resolution its two readers take, so they cannot disagree: the + * save door, which then writes as a save naming that package does, and + * {@link readVersionToken}, whose `version` a save at the same address + * accepts. + * + * Why the package's row: a save writes exactly one + * `(org, type, name, package_id)` row, and the item read serves the + * package's row whenever it is the item's only row. A save that wrote the + * package-unbound row instead minted a second row beside it and answered + * `200` for a write that read never served. The draft save has resolved + * its address this way since #11087 (`SysMetadataRepository.resolveWriteHead`); + * the direct save did not, and this is that half. + * + * Bindings only: a package-less row beside the package's row (minted by + * that defect before this resolution) is not a second package, and stays + * as it is. An item with no binding keeps the package-unbound row, as + * before. A failed read propagates — guessing the binding would choose the + * row the save lands in. + */ + private async packagelessSaveAddress( + repo: SysMetadataRepository, + ref: Parameters[0], + /** [#22877] The bundle member the save addresses (its body's); omitted, the canonical one. */ + member?: string, + ): Promise<{ packageId: string | null; candidates: readonly string[] }> { + const candidates = await repo.activeBindingsAt(ref, { member }); + return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates }; + } + /** * 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 @@ -20877,6 +20961,51 @@ export class ObjectStackProtocolImplementation implements if (lockErr) throw lockErr; } + // [#23165, ADR-0048] THE WRITE ADDRESS of a direct save that names no + // package: the item's own package when its active rows are bound to + // exactly one ({@link packagelessSaveAddress}). It used to be the + // package-unbound row, always, so a save of a package's item minted a + // second row beside the package's and answered `200` while the item + // read kept serving the package's row. The draft save already resolved + // its address this way (#11087, `SysMetadataRepository.resolveWriteHead`). + // + // Resolved ONCE, here, and from here on the save IS a save naming that + // package: every gate below that asks about the row this save writes + // (the D9.9 object check, the stored lock family, the runtime + // authoring gate, ADR-0070 D1's writable-base check, the lock parent, + // the credential carry-forward, `repo.put`, the registry write-through + // and the receipt) reads the same `packageId`, so no two of them can + // judge different rows. A package that is read-only answers here what + // `?package=` naming it answers. + // + // Below the authorization refusals on purpose: the code-only refusal, + // the package door and the `_lock` gate judge the item, not its row, + // and they answer before this store read, as #22220 ordered. + // + // Not asked of: a save that names a package (ADR-0048, its address is + // its own) or the package-unbound row (`packageId: null`, which the + // stored-metadata migration and the stored flow-credential move pass + // for a package-less row they rewrite); a draft save, whose binding the + // repository resolves (#11087). + // + // Several bindings is a name more than one package holds, and a save + // naming none of them is refused, not guessed. + if (mode === 'publish' && request.packageId === undefined) { + const address = await this.packagelessSaveAddress( + this.getOverlayRepo(), + { + type: PLURAL_TO_SINGULAR[request.type] ?? request.type, + name: request.name, + org: 'env', + } as Parameters[0], + itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), + ); + if (address.candidates.length > 1) { + throw packagelessSaveAmbiguityRefusal(request.type, request.name, address.candidates); + } + if (address.packageId !== null) request = { ...request, packageId: address.packageId }; + } + const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; // [ADR-0029 D9.9 / #6995] The refusal that is about REPRESENTABILITY @@ -21749,6 +21878,9 @@ 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}` + : ''; return { success: true, // [#21207] The version token: the keyed form of the stored @@ -21804,9 +21936,15 @@ export class ObjectStackProtocolImplementation implements // claim for another. Distinguishing it honestly needs a read // this path does not already make, and a receipt is not worth // a query — so the verb stays the neutral, true "Saved". + // + // [#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 packagelessSaveAddress}). A save into the + // package-unbound row reads exactly as it did. message: artifactBacked - ? `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` - : `Saved ${singularTypeForRepo} '${request.name}' (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`, + ? `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}]`, }; } catch (err: any) { if (err instanceof ConflictError) throw await this.saveConflict(err, request, writeSource); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 27d806dc580..666ede649f1 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -752,27 +752,11 @@ export class SysMetadataRepository implements MetadataRepository { let targetPackageId: string | null = packageId ?? null; if (state === 'draft' && packageId == null) { // The overlaid base is resolved with the SAME two-scope reach the draft - // list applies (`listDrafts`' $or contract): an org-scoped caller's - // active row usually lives ENV-WIDE (`organization_id IS NULL`) — the - // ADR-0005 overlay order — so a same-org-only lookup here found nothing - // for exactly the console-session saves this inheritance exists for - // (measured live: an org-scoped view draft stayed `package_id NULL` - // over an env-wide active row bound to `app.k9qk`). - const activeWhere: Record = { - type: ref.type, - name: ref.name, - state: 'active', - }; - if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; - if (this.organizationId != null) { - activeWhere.$or = [ - { organization_id: this.organizationId }, - { organization_id: null }, - ]; - } else { - activeWhere.organization_id = null; - } - const activeRow = await this.engine.findOne('sys_metadata', { where: activeWhere, context }); + // list applies (`listDrafts`' $or contract) — see {@link activeBaseWhere}. + const activeRow = await this.engine.findOne('sys_metadata', { + where: this.activeBaseWhere(ref, member), + context, + }); const activePkg = (activeRow as { package_id?: string | null } | null)?.package_id ?? null; if (activePkg) targetPackageId = activePkg; } @@ -795,6 +779,78 @@ export class SysMetadataRepository implements MetadataRepository { return { row: row ?? null, targetPackageId }; } + /** + * [#11087, #23165] The `where` of the item's ACTIVE rows at one address — + * the published base a package-less save sits over — with the two-scope + * reach the draft list applies (`listDrafts`' $or contract): an org-scoped + * caller's active row usually lives ENV-WIDE (`organization_id IS NULL`), + * the ADR-0005 overlay order, so a same-org-only lookup found nothing for + * exactly the console-session saves the draft inheritance exists for + * (measured live: an org-scoped view draft stayed `package_id NULL` over an + * env-wide active row bound to `app.k9qk`). No package predicate: every + * binding at the address is in it. + * + * ONE builder for its two readers, {@link resolveWriteHead}'s draft + * inheritance and {@link activeBindingsAt}, so the two cannot reach + * different rows. + */ + private activeBaseWhere(ref: Pick, member?: string | null): Record { + const activeWhere: Record = { + type: ref.type, + name: ref.name, + state: 'active', + }; + if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; + if (this.organizationId != null) { + activeWhere.$or = [ + { organization_id: this.organizationId }, + { organization_id: null }, + ]; + } else { + activeWhere.organization_id = null; + } + return activeWhere; + } + + /** + * [#23165] The packages the item's ACTIVE rows at one address are bound + * to: each distinct non-null `package_id`, sorted. A package-less row + * contributes nothing, so `[]` answers "no binding", whether the address + * holds no row or only package-less ones. + * + * The fact a save that names NO package resolves its write address from + * (`saveMetaItem`'s direct save, and the item read's `version` token for + * the same address): one binding is the package whose row the item read + * serves, so the save writes that row; several is a name more than one + * package holds, which a save naming none cannot address. A `sys_metadata` + * row is keyed `(org, type, name, package_id)`, and #8907 measured the + * package-agnostic `findOne` such a save would otherwise lean on as a + * driver-order coin toss — so the bindings are read in full, never sampled. + * + * The reach is the draft inheritance's ({@link activeBaseWhere}); `member` + * is the bundle member the save addresses, as {@link headAt} takes it. + * ⛔ Not a write-address default: {@link put} and {@link headAt} keep + * `PutOptions.packageId`'s documented one (omitted = the package-unbound + * row), so a caller that states the unbound row (`promoteDraft` and + * `restoreVersion` of a package-less row, the stored-metadata migration) + * keeps it. A failed read propagates. + */ + async activeBindingsAt( + ref: Pick, + opts: { member?: string | null } = {}, + ): Promise { + this.assertOpen(); + const rows = await this.engine.find('sys_metadata', { + where: this.activeBaseWhere(ref, overlayMemberValue(ref.type, opts.member)), + context: { isSystem: true }, + }); + const bindings = new Set(); + for (const row of (rows ?? []) as Array<{ package_id?: unknown }>) { + if (typeof row?.package_id === 'string' && row.package_id !== '') bindings.add(row.package_id); + } + return [...bindings].sort(); + } + /** * Resolve a historical version by content hash (ADR-0009). * From 0179aba6717ec799421ea1f7615679592e8b98b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:04:01 +0000 Subject: [PATCH 02/12] test(metadata-protocol): pin where a direct save naming no package writes Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- .../protocol.packageless-save-address.test.ts | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.packageless-save-address.test.ts diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts new file mode 100644 index 00000000000..f82bbd228f9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23165, ADR-0048] Where a direct save that names no package writes. + * + * A `sys_metadata` row is keyed `(org, type, name, package_id)`. A save that + * named no package wrote the package-unbound row, always: for an item a + * package owns (`PUT /api/v1/meta/app/lanebapp_app` with no `?package=`, after + * the item was saved into `com.qa.lanebapp`), it minted a second, package-less + * row beside the package's and answered `200` "Saved app 'lanebapp_app' + * (env-wide, state=active)", while the item read kept serving the package's + * row. Measured on a booted showcase: the console's "Disable" showed success + * and nothing changed. The draft save already resolved its address from the + * package of the item's active row (#11087, `resolveWriteHead`); the direct + * save did not. + * + * Pinned here, through the real save and read doors: + * + * - the round trip: a package-less save of a single-package item writes the + * package's row, the item read serves it next, no second row is minted, and + * the receipt names the package; + * - the item read's `version` names the row that save writes, so a read + * followed by a save with the served token is accepted; + * - an item two packages hold: the package-less save is refused, + * `409 RESOURCE_CONFLICT`, naming both, and nothing is written; naming one + * package saves. The two-package state is measured reachable through the + * door first; + * - the save is a save naming the resolved package: the row keeps its stored + * lock family, and a read-only package answers what `?package=` naming it + * answers; + * - CONTROL: an item with no package binding keeps the package-unbound row, + * and its receipt reads byte for byte as before; + * - CONTROL: a package-less row minted beside a package's row before the fix + * stays as it is, and a save naming the unbound row (`packageId: null`) + * still writes it; + * - an item a code package ships, with no stored row: the package-less save + * writes the package-unbound overlay and the read serves it, as before; with + * an overlay row already bound to the code package, it writes that row; + * - the draft path: a package-less draft save still inherits the package + * (its own suite, `sys-metadata-repository.draft-package-inherit.test.ts`, + * holds the rest). + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`): `code` and `status`, never a bare throw. + * + * The engine below is a stub: this package cannot import `@objectstack/objectql` + * (that package depends on this one). It keeps real rows, so the REAL save and + * read doors run against it, and its write verbs route through the producer's + * own dispatch predicates. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +type Row = Record; + +/** A writable runtime package: no manifest booted it, and no read-only scope. */ +const WRITABLE = 'com.qa.lanebapp'; +const OTHER_WRITABLE = 'com.qa.otherapp'; +/** A code package: its manifest was booted, so it is read-only (ADR-0070). */ +const CODE_PACKAGE = 'com.example.showcase'; +/** An installed package with a read-only manifest scope. */ +const INSTALLED_READ_ONLY = 'com.vendor.installed'; + +/** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); + if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); + if ((row[key] ?? null) !== want) return false; + } + return true; +} + +/** + * A stub engine that keeps rows, and a registry holding what a code package's + * loader registered (each artifact as `applyProtection` leaves it). + */ +function makeProtocol(artifacts: Array<{ type: string; item: Record }> = []) { + const tables = new Map(); + const table = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + let nextId = 0; + const writes: Array<{ op: string; table: string }> = []; + const shipped = (type: string, name: string) => artifacts + .filter((a) => a.type === type && a.item.name === name) + .map((a) => a.item); + const engine: any = { + manifests: new Set([CODE_PACKAGE]), + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + writes.push({ op: 'insert', table: t }); + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: Record) { + assertEngineUpdateDispatch(data, q); + writes.push({ op: 'update', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const hit = table(t).filter((r) => matches(r, where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: Record) { + assertEngineDeleteDispatch(q); + writes.push({ op: 'delete', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const keep = table(t).filter((r) => !matches(r, where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + getArtifactItem: (type: string, name: string) => shipped(type, name).find((item) => isCodeArtifactBody(item)), + getItem: (type: string, name: string) => shipped(type, name)[0], + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => a.item), + getObject: () => undefined, + getPackage: (id: string) => (id === INSTALLED_READ_ONLY ? { manifest: { id, scope: 'cloud' } } : undefined), + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + const metadataWrites = () => writes.filter((w) => w.table === 'sys_metadata' && w.op !== 'delete'); + const rows = (type: string, name: string, state = 'active') => + table('sys_metadata').filter((r) => r.type === type && r.name === name && r.state === state); + /** A row as an older build left it, written straight into the table. */ + const seedRow = (row: Row) => { + nextId += 1; + table('sys_metadata').push({ id: `seed_${nextId}`, organization_id: null, state: 'active', version: 1, ...row }); + }; + return { protocol, metadataWrites, rows, seedRow }; +} + +const app = (name: string, extra: Record = {}) => ({ name, label: `App ${name}`, ...extra }); + +/** The stored body of each row, by binding: `null` for the package-unbound row. */ +function bodiesByBinding(rows: Row[]): Record> { + return Object.fromEntries(rows.map((r) => [String(r.package_id ?? null), JSON.parse(r.metadata)])); +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +/** A view a code package ships, as its loader registers it. */ +function packagedView(name: string) { + const item: Record = { + name, label: `Packaged ${name}`, object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }], + }; + applyProtection(item, { packageId: CODE_PACKAGE, packageVersion: '1.0.0' }); + return { type: 'view', item }; +} + +const viewBody = (name: string, label: string) => ({ + name, label, object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }], +}); + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_METADATA_WRITABLE'] as const; +const savedEnv: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetEnvWritableMetadataTypes(); +}); + +describe('a package-less direct save of an item ONE package owns writes that package\'s row', () => { + it('the card\'s probe: the write is what the item read serves next, no second row, and the receipt names the package', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'lanebapp_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { active: false }) }); + + expect(saved.success).toBe(true); + expect(saved.message).toBe(`Saved app '${name}' (env-wide, package=${WRITABLE}, state=active) [seq=${saved.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ active: false }); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read.item).toMatchObject({ active: false, _packageId: WRITABLE }); + // The read naming the package serves the same row. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item).toMatchObject({ active: false }); + }); + + it('both author faces resolve the same address', async () => { + for (const writeFace of ['meta-envelope', 'meta-dispatch'] as const) { + const { protocol, rows } = makeProtocol(); + const name = `face_${writeFace.replace('-', '_')}`; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE, writeFace }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), writeFace }); + expect(rows('app', name).map((r) => r.package_id ?? null), writeFace).toEqual([WRITABLE]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label, writeFace).toBe('Edited'); + } + }); + + it('the item read\'s version names the row that save writes, so read → save with the served token is accepted', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'token_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read.version).toEqual(expect.any(String)); + // The same token the read naming the package serves: one address. + expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).version).toBe(read.version); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pinned' }), parentVersion: read.version }); + expect(saved.success).toBe(true); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + + // The token it replaced is stale now, and a create assertion meets a + // row that exists: both are the optimistic lock's 409. + for (const parentVersion of [read.version, null]) { + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Stale' }), parentVersion, + }))); + expect(envelope.status, String(parentVersion)).toBe(409); + expect(envelope.code, String(parentVersion)).toBe('METADATA_CONFLICT'); + } + expect(rows('app', name)).toHaveLength(1); + }); + + it('the save is a save naming that package: the row keeps the lock family it stores', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'stamped_app'; + const stamped = { _lock: 'no-delete', _lockReason: 'Stamped by the platform.', _lockSource: 'package' }; + // A server face stamps the family onto the package's row. + await protocol.saveMetaItem({ + type: 'app', name, item: app(name, stamped), packageId: WRITABLE, source: 'migrate-stored', force: true, + }); + + // An author save that says nothing about the lock. + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + expect(rows('app', name)).toHaveLength(1); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'Edited', ...stamped }); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read).toMatchObject({ lock: 'no-delete', deletable: false }); + }); + + it('a read-only package answers what ?package= naming it answers', async () => { + const { protocol, rows, seedRow, metadataWrites } = makeProtocol(); + const name = 'installed_app'; + seedRow({ type: 'app', name, package_id: INSTALLED_READ_ONLY, metadata: JSON.stringify(app(name)), checksum: 'sha-installed' }); + + const named = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Edited' }), packageId: INSTALLED_READ_ONLY, + }))); + const unnamed = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Edited' }), + }))); + + expect(named).toMatchObject({ status: 422, code: 'WRITABLE_PACKAGE_REQUIRED' }); + expect(unnamed).toMatchObject({ status: named.status, code: named.code }); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([INSTALLED_READ_ONLY]); + expect(metadataWrites()).toEqual([]); + }); +}); + +describe('an item TWO packages hold: a save naming neither is refused', () => { + async function twoPackageItem(name: string) { + const fixture = makeProtocol(); + // Reachable through the door: each package-scoped save writes its own row. + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A' }), packageId: WRITABLE }); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'B' }), packageId: OTHER_WRITABLE }); + return fixture; + } + + it('two package-scoped saves leave one active row per package', async () => { + const { rows } = await twoPackageItem('shared_app'); + expect(rows('app', 'shared_app').map((r) => r.package_id).sort()).toEqual([WRITABLE, OTHER_WRITABLE].sort()); + }); + + it('409 RESOURCE_CONFLICT naming both packages, and nothing is written', async () => { + const name = 'shared_app'; + const { protocol, rows, metadataWrites } = await twoPackageItem(name); + const before = metadataWrites().length; + const bodiesBefore = bodiesByBinding(rows('app', name)); + + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, { active: false }) })); + + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('RESOURCE_CONFLICT'); + expect(err.message).toContain(`'${WRITABLE}'`); + expect(err.message).toContain(`'${OTHER_WRITABLE}'`); + expect(err.message).toContain('?package='); + expect(metadataWrites()).toHaveLength(before); + expect(rows('app', name)).toHaveLength(2); + expect(bodiesByBinding(rows('app', name))).toEqual(bodiesBefore); + }); + + it('naming one of them saves that package\'s row', async () => { + const name = 'shared_app'; + const { protocol, rows } = await twoPackageItem(name); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A2' }), packageId: WRITABLE }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect(bodiesByBinding(rows('app', name))).toMatchObject({ [WRITABLE]: { label: 'A2' }, [OTHER_WRITABLE]: { label: 'B' } }); + }); +}); + +describe('CONTROL: what the resolution leaves alone', () => { + it('an item with no package binding keeps the package-unbound row, and its receipt reads as before', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'plain_app'; + const first = await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); + const second = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + expect(first.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${first.seq}]`); + expect(second.message).toBe(`Saved app '${name}' (env-wide, state=active) [seq=${second.seq}]`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('Edited'); + }); + + it('a package-less row minted beside the package\'s row stays as it is; the save writes the package\'s row', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }) }); + + const bodies = bodiesByBinding(rows('app', name)); + expect(Object.keys(bodies).sort()).toEqual([WRITABLE, 'null'].sort()); + expect(bodies[WRITABLE]).toMatchObject({ label: 'Edited' }); + expect(bodies.null).toEqual(app(name, { active: false })); + }); + + it('a save naming the package-unbound row (packageId: null) still writes it', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name)), checksum: 'sha-residue' }); + + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Unbound' }), packageId: null }); + + const bodies = bodiesByBinding(rows('app', name)); + expect(bodies.null).toMatchObject({ label: 'Unbound' }); + expect(bodies[WRITABLE]).toEqual(app(name)); + }); + + it('a package-less DRAFT save still inherits the package of the active row (#11087)', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'draft_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft' }); + + expect(saved.state).toBe('draft'); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); +}); + +describe('an item a CODE package ships', () => { + it('with no stored row: the package-less save writes the package-unbound overlay, and the read serves it', async () => { + const name = 'showcase_task_grid'; + const { protocol, rows } = makeProtocol([packagedView(name)]); + + const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }); + + expect(saved.message).toBe( + `Saved customization overlay (env-wide, state=active) — type=view, name=${name} [seq=${saved.seq}]`, + ); + expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Overlaid'); + }); + + it('with an overlay row bound to the code package: the package-less save writes that row', async () => { + const name = 'showcase_task_grid'; + const { protocol, rows } = makeProtocol([packagedView(name)]); + // The overlay a save naming the package writes (ADR-0005: an overlay of + // a code-shipped item names the package it customizes). + await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Named'), packageId: CODE_PACKAGE }); + + const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }); + + expect(saved.message).toContain(`package=${CODE_PACKAGE}`); + expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]); + expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Unnamed'); + }); +}); From f95a49fc62209a7d0fba2efb65954e0c2cc7f8ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:17:43 +0000 Subject: [PATCH 03/12] test(metadata-protocol): the stand-in copy beside a package's copy is written naming the unbound row A save naming no package now writes the row of the one package the item is bound to, so the order pin writes its package-less copy as packageId null, the row it stood beside before. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- .../src/protocol.org-scoped-write-refused.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 67bcf0f9c75..8cb5a5533ad 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 @@ -751,9 +751,9 @@ describe('each package\'s copy of a view container expands into its own package\ } /** An env-wide copy of the container, stored for `packageId` (package-less when undefined). */ - async function saveCopy(protocol: any, packageId: string | undefined, allowAnonymous: boolean, title: string) { + async function saveCopy(protocol: any, packageId: string | null | undefined, allowAnonymous: boolean, title: string) { expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId ? { packageId } : {}), + type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId !== undefined ? { packageId } : {}), })).success).toBe(true); } @@ -839,8 +839,16 @@ describe('each package\'s copy of a view container expands into its own package\ it(`${kernel}, ${order[0] ?? 'the package-less'} copy saved first: a package's own copy serves that package's slot ahead of the package-less copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); await restoreOpenOverlay(protocol); + // [#23165] The package-less copy is written NAMING the + // package-unbound row (`packageId: null`). A save naming no + // package now writes the row of the one package the item is + // bound to, so after pkg_b's copy it would update that copy + // instead of standing a package-less one beside it. Named, + // the row lands where it did before in both orders — the + // shape a copy saved before #23165 left — which is what + // this pin reads. for (const pkg of order) { - await saveCopy(protocol, pkg, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); + await saveCopy(protocol, pkg ?? null, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); } expect(await envWideBodies(protocol)).toEqual([ From 095bc3d4ec8fd3f06704280b7d4d7268c29f98bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:31:35 +0000 Subject: [PATCH 04/12] refactor(metadata-protocol): read a package-less save's bindings at the door, leaving the repository's surface as it was The bindings are read over the rows the item read chooses among, in the protocol, so SysMetadataRepository gains no public method and its write address default is untouched. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 81 +++++++++------ .../src/sys-metadata-repository.ts | 98 ++++--------------- 2 files changed, 73 insertions(+), 106 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b065785f830..5fcf9b7673a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18554,7 +18554,7 @@ export class ObjectStackProtocolImplementation implements org: 'env', } as Parameters[0]; const packageId = request.packageId === undefined && state === 'active' - ? (await this.packagelessSaveAddress(repo, ref, request.member)).packageId + ? (await this.packagelessSaveAddress(ref.type, ref.name, request.member)).packageId : request.packageId ?? null; const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); return stored === null ? null : this.receiptVersion(stored); @@ -18562,12 +18562,11 @@ export class ObjectStackProtocolImplementation implements /** * [#23165, ADR-0048] Where a DIRECT (active) save that names no package - * writes: the item's own package when its active rows at the address are - * bound to exactly one, else the package-unbound row. `candidates` is every - * binding ({@link SysMetadataRepository.activeBindingsAt}); two or more is - * a name several packages hold, which {@link saveMetaItem} refuses - * ({@link packagelessSaveAmbiguityRefusal}) and for which `packageId` is - * `null`. + * writes: the item's own package when its active rows are bound to exactly + * one, else the package-unbound row. `candidates` is every distinct + * binding, sorted; two or more is a name several packages hold, which + * {@link saveMetaItem} refuses ({@link packagelessSaveAmbiguityRefusal}) + * and for which `packageId` is `null`. * * The one resolution its two readers take, so they cannot disagree: the * save door, which then writes as a save naming that package does, and @@ -18575,26 +18574,55 @@ export class ObjectStackProtocolImplementation implements * accepts. * * Why the package's row: a save writes exactly one - * `(org, type, name, package_id)` row, and the item read serves the - * package's row whenever it is the item's only row. A save that wrote the - * package-unbound row instead minted a second row beside it and answered - * `200` for a write that read never served. The draft save has resolved - * its address this way since #11087 (`SysMetadataRepository.resolveWriteHead`); - * the direct save did not, and this is that half. - * - * Bindings only: a package-less row beside the package's row (minted by - * that defect before this resolution) is not a second package, and stays - * as it is. An item with no binding keeps the package-unbound row, as - * before. A failed read propagates — guessing the binding would choose the - * row the save lands in. + * `(org, type, name, package_id)` row, and the item read that names no + * package serves the package's row whenever it is the item's only row. A + * save that wrote the package-unbound row instead minted a second row + * beside it and answered `200` for a write that read never served. The + * draft save has resolved its address from the item's active row since + * #11087 (`SysMetadataRepository.resolveWriteHead`); the direct save did + * not, and this is that half. + * + * The rows are the ones the item read chooses among + * ({@link findServedOverlayRow}): the environment's (ADR-0131 D6), the + * active state, the canonical type the save writes under, the bundle + * member the save addresses (#22877), and no package predicate. They are + * read in full, never sampled: with two packages' rows at one address a + * package-agnostic `findOne` is a driver-order coin toss (#8907). A + * package-less row beside the package's row (minted by the defect this + * fixes) is not a second package, and stays as it is. An item with no + * binding keeps the package-unbound row, as before. An unprovisioned store + * holds no row; any other failed read propagates (#5532), because guessing + * the binding would choose the row the save lands in. + * + * ⛔ Not the repository's write-address default: `put` and `headAt` keep + * `PutOptions.packageId`'s documented one (omitted = the package-unbound + * row), because a caller that names the unbound row (`promoteDraft` and + * `restoreVersion` of a package-less row, the stored-metadata migration) + * means it. The save door resolves, then names the package. */ private async packagelessSaveAddress( - repo: SysMetadataRepository, - ref: Parameters[0], + type: string, + name: string, /** [#22877] The bundle member the save addresses (its body's); omitted, the canonical one. */ member?: string, ): Promise<{ packageId: string | null; candidates: readonly string[] }> { - const candidates = await repo.activeBindingsAt(ref, { member }); + let rows: Array<{ package_id?: unknown }> | null | undefined; + try { + // [#21911] The explicit system opt-in — see findServedOverlayRow. + rows = await this.engine.find('sys_metadata', { + where: { type, name, state: 'active', organization_id: null, ...overlayMemberWhere(type, member) }, + context: { isSystem: true }, + }); + } catch (error) { + // [#5532] An unprovisioned store holds no row, so no binding; any + // other failure is a read that did not happen, never "no binding". + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const bindings = new Set(); + for (const row of rows ?? []) { + if (typeof row?.package_id === 'string' && row.package_id !== '') bindings.add(row.package_id); + } + const candidates = [...bindings].sort(); return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates }; } @@ -20991,14 +21019,9 @@ export class ObjectStackProtocolImplementation implements // Several bindings is a name more than one package holds, and a save // naming none of them is refused, not guessed. if (mode === 'publish' && request.packageId === undefined) { + const writeType = PLURAL_TO_SINGULAR[request.type] ?? request.type; const address = await this.packagelessSaveAddress( - this.getOverlayRepo(), - { - type: PLURAL_TO_SINGULAR[request.type] ?? request.type, - name: request.name, - org: 'env', - } as Parameters[0], - itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), + writeType, request.name, itemDiscriminator(writeType, request.item), ); if (address.candidates.length > 1) { throw packagelessSaveAmbiguityRefusal(request.type, request.name, address.candidates); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 666ede649f1..27d806dc580 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -752,11 +752,27 @@ export class SysMetadataRepository implements MetadataRepository { let targetPackageId: string | null = packageId ?? null; if (state === 'draft' && packageId == null) { // The overlaid base is resolved with the SAME two-scope reach the draft - // list applies (`listDrafts`' $or contract) — see {@link activeBaseWhere}. - const activeRow = await this.engine.findOne('sys_metadata', { - where: this.activeBaseWhere(ref, member), - context, - }); + // list applies (`listDrafts`' $or contract): an org-scoped caller's + // active row usually lives ENV-WIDE (`organization_id IS NULL`) — the + // ADR-0005 overlay order — so a same-org-only lookup here found nothing + // for exactly the console-session saves this inheritance exists for + // (measured live: an org-scoped view draft stayed `package_id NULL` + // over an env-wide active row bound to `app.k9qk`). + const activeWhere: Record = { + type: ref.type, + name: ref.name, + state: 'active', + }; + if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; + if (this.organizationId != null) { + activeWhere.$or = [ + { organization_id: this.organizationId }, + { organization_id: null }, + ]; + } else { + activeWhere.organization_id = null; + } + const activeRow = await this.engine.findOne('sys_metadata', { where: activeWhere, context }); const activePkg = (activeRow as { package_id?: string | null } | null)?.package_id ?? null; if (activePkg) targetPackageId = activePkg; } @@ -779,78 +795,6 @@ export class SysMetadataRepository implements MetadataRepository { return { row: row ?? null, targetPackageId }; } - /** - * [#11087, #23165] The `where` of the item's ACTIVE rows at one address — - * the published base a package-less save sits over — with the two-scope - * reach the draft list applies (`listDrafts`' $or contract): an org-scoped - * caller's active row usually lives ENV-WIDE (`organization_id IS NULL`), - * the ADR-0005 overlay order, so a same-org-only lookup found nothing for - * exactly the console-session saves the draft inheritance exists for - * (measured live: an org-scoped view draft stayed `package_id NULL` over an - * env-wide active row bound to `app.k9qk`). No package predicate: every - * binding at the address is in it. - * - * ONE builder for its two readers, {@link resolveWriteHead}'s draft - * inheritance and {@link activeBindingsAt}, so the two cannot reach - * different rows. - */ - private activeBaseWhere(ref: Pick, member?: string | null): Record { - const activeWhere: Record = { - type: ref.type, - name: ref.name, - state: 'active', - }; - if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; - if (this.organizationId != null) { - activeWhere.$or = [ - { organization_id: this.organizationId }, - { organization_id: null }, - ]; - } else { - activeWhere.organization_id = null; - } - return activeWhere; - } - - /** - * [#23165] The packages the item's ACTIVE rows at one address are bound - * to: each distinct non-null `package_id`, sorted. A package-less row - * contributes nothing, so `[]` answers "no binding", whether the address - * holds no row or only package-less ones. - * - * The fact a save that names NO package resolves its write address from - * (`saveMetaItem`'s direct save, and the item read's `version` token for - * the same address): one binding is the package whose row the item read - * serves, so the save writes that row; several is a name more than one - * package holds, which a save naming none cannot address. A `sys_metadata` - * row is keyed `(org, type, name, package_id)`, and #8907 measured the - * package-agnostic `findOne` such a save would otherwise lean on as a - * driver-order coin toss — so the bindings are read in full, never sampled. - * - * The reach is the draft inheritance's ({@link activeBaseWhere}); `member` - * is the bundle member the save addresses, as {@link headAt} takes it. - * ⛔ Not a write-address default: {@link put} and {@link headAt} keep - * `PutOptions.packageId`'s documented one (omitted = the package-unbound - * row), so a caller that states the unbound row (`promoteDraft` and - * `restoreVersion` of a package-less row, the stored-metadata migration) - * keeps it. A failed read propagates. - */ - async activeBindingsAt( - ref: Pick, - opts: { member?: string | null } = {}, - ): Promise { - this.assertOpen(); - const rows = await this.engine.find('sys_metadata', { - where: this.activeBaseWhere(ref, overlayMemberValue(ref.type, opts.member)), - context: { isSystem: true }, - }); - const bindings = new Set(); - for (const row of (rows ?? []) as Array<{ package_id?: unknown }>) { - if (typeof row?.package_id === 'string' && row.package_id !== '') bindings.add(row.package_id); - } - return [...bindings].sort(); - } - /** * Resolve a historical version by content hash (ADR-0009). * From cd7d913909f1f835a5c603d830fed22f85a6e56d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:34:20 +0000 Subject: [PATCH 05/12] fix(metadata-protocol): a null packageId names no package on an author save, the spec says what an active save naming none writes The door resolves for an authoring publish whose packageId is absent or null, as the request contract states; the server-stated rewrites keep the row they name. The order pin's package-less copy beside a package's copy is written by that server-stated face. Changeset added. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- .../23165-direct-save-resolves-package.md | 32 +++++++++++++++++ .../protocol.org-scoped-write-refused.test.ts | 34 +++++++++++++------ .../protocol.packageless-save-address.test.ts | 23 ++++++++++--- packages/metadata-protocol/src/protocol.ts | 31 +++++++++++------ packages/spec/src/api/protocol.zod.ts | 9 +++-- 5 files changed, 100 insertions(+), 29 deletions(-) create mode 100644 .changeset/23165-direct-save-resolves-package.md diff --git a/.changeset/23165-direct-save-resolves-package.md b/.changeset/23165-direct-save-resolves-package.md new file mode 100644 index 00000000000..71fac89e6c2 --- /dev/null +++ b/.changeset/23165-direct-save-resolves-package.md @@ -0,0 +1,32 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +--- + +fix(metadata-protocol)!: a direct metadata save that names no package writes the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`, instead of answering `200` for a package-less row the item read never serves (ADR-0048) + +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 one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. The direct save did not. + +**FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. The item read that names no package served `version: null` for such an item, as if a save would create it. + +**TO.** A direct save that names no package resolves its address from the item's active rows: + +- **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next. The receipt names the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)". Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save with the served `If-Match` is accepted. +- **Bound to two or more packages.** The save is refused `409 RESOURCE_CONFLICT` and writes nothing. The message names the packages and the remedy: name one with `?package=`. +- **Bound to none.** The save writes the package-unbound row, as before, and its receipt reads as before. + +**Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. + +`packageId: null` in process names no package too, as the request contract states. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. + +**Unchanged.** A save that names a package. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted or deleted. + +**Remedy.** For an item two packages hold, send the save with `?package=` naming the package whose row it edits. A save receipt now names the package when the save wrote a package's row; a client that compares the receipt text should expect `, package=` after `env-wide`. + +`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay. Description only; the schema is unchanged. 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 8cb5a5533ad..931912d2a98 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 @@ -751,9 +751,25 @@ describe('each package\'s copy of a view container expands into its own package\ } /** An env-wide copy of the container, stored for `packageId` (package-less when undefined). */ - async function saveCopy(protocol: any, packageId: string | null | undefined, allowAnonymous: boolean, title: string) { + async function saveCopy(protocol: any, packageId: string | undefined, allowAnonymous: boolean, title: string) { expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId !== undefined ? { packageId } : {}), + type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId ? { packageId } : {}), + })).success).toBe(true); + } + + /** + * [#23165] A package-less copy stored BESIDE a package's copy. An author + * save naming no package now writes the row of the one package the item is + * bound to, so after pkg_b's copy it would update that copy instead. The + * row this pin reads is the package-less row such a save left before, and + * the door still writes it for a server-stated rewrite of that row + * (`packageId: null` with `source`, the stored-metadata migration's + * spelling), write-through included. + */ + async function saveUnboundCopyBeside(protocol: any, allowAnonymous: boolean, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: container(allowAnonymous, title), packageId: null, + source: 'migrate-stored', force: true, })).success).toBe(true); } @@ -839,16 +855,12 @@ describe('each package\'s copy of a view container expands into its own package\ it(`${kernel}, ${order[0] ?? 'the package-less'} copy saved first: a package's own copy serves that package's slot ahead of the package-less copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); await restoreOpenOverlay(protocol); - // [#23165] The package-less copy is written NAMING the - // package-unbound row (`packageId: null`). A save naming no - // package now writes the row of the one package the item is - // bound to, so after pkg_b's copy it would update that copy - // instead of standing a package-less one beside it. Named, - // the row lands where it did before in both orders — the - // shape a copy saved before #23165 left — which is what - // this pin reads. for (const pkg of order) { - await saveCopy(protocol, pkg ?? null, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); + if (pkg === undefined && order[0] === 'pkg_b') { + await saveUnboundCopyBeside(protocol, true, 'Intake (env-wide copy)'); + continue; + } + await saveCopy(protocol, pkg, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); } expect(await envWideBodies(protocol)).toEqual([ 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 f82bbd228f9..fe2e8c9d823 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -30,9 +30,11 @@ * answers; * - CONTROL: an item with no package binding keeps the package-unbound row, * and its receipt reads byte for byte as before; + * - `packageId: null` names no package, as `undefined` does (the request + * contract states both); * - CONTROL: a package-less row minted beside a package's row before the fix - * stays as it is, and a save naming the unbound row (`packageId: null`) - * still writes it; + * stays as it is, and a server-stated rewrite of that row (the + * stored-metadata migration passes `packageId: null`) still writes it; * - an item a code package ships, with no stored row: the package-less save * writes the package-unbound overlay and the read serves it, as before; with * an overlay row already bound to the code package, it writes that row; @@ -212,6 +214,15 @@ describe('a package-less direct save of an item ONE package owns writes that pac expect((await protocol.getMetaItem({ type: 'app', name, packageId: WRITABLE })).item).toMatchObject({ active: false }); }); + it('packageId: null names no package too, as the request contract states', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'null_named_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + const saved = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), packageId: null }); + expect(saved.message).toContain(`package=${WRITABLE}`); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); + it('both author faces resolve the same address', async () => { for (const writeFace of ['meta-envelope', 'meta-dispatch'] as const) { const { protocol, rows } = makeProtocol(); @@ -355,16 +366,18 @@ describe('CONTROL: what the resolution leaves alone', () => { expect(bodies.null).toEqual(app(name, { active: false })); }); - it('a save naming the package-unbound row (packageId: null) still writes it', async () => { + it('a server-stated rewrite of the package-unbound row (the stored-metadata migration, packageId: null) still writes that row', async () => { const { protocol, rows, seedRow } = makeProtocol(); const name = 'residue_app'; await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name)), checksum: 'sha-residue' }); - await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Unbound' }), packageId: null }); + await protocol.saveMetaItem({ + type: 'app', name, item: app(name, { label: 'Rewritten' }), packageId: null, source: 'migrate-stored', force: true, + }); const bodies = bodiesByBinding(rows('app', name)); - expect(bodies.null).toMatchObject({ label: 'Unbound' }); + expect(bodies.null).toMatchObject({ label: 'Rewritten' }); expect(bodies[WRITABLE]).toEqual(app(name)); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5fcf9b7673a..c27596871c2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18553,7 +18553,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: 'env', } as Parameters[0]; - const packageId = request.packageId === undefined && state === 'active' + const packageId = request.packageId == null && state === 'active' ? (await this.packagelessSaveAddress(ref.type, ref.name, request.member)).packageId : request.packageId ?? null; const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); @@ -18596,9 +18596,10 @@ export class ObjectStackProtocolImplementation implements * * ⛔ Not the repository's write-address default: `put` and `headAt` keep * `PutOptions.packageId`'s documented one (omitted = the package-unbound - * row), because a caller that names the unbound row (`promoteDraft` and - * `restoreVersion` of a package-less row, the stored-metadata migration) - * means it. The save door resolves, then names the package. + * row), because the callers that reach them with no package + * (`promoteDraft` and `restoreVersion` of a package-less row) mean that + * row, as do the server-stated rewrites the save door does not resolve + * for. The save door resolves, then names the package. */ private async packagelessSaveAddress( type: string, @@ -21010,15 +21011,25 @@ export class ObjectStackProtocolImplementation implements // the package door and the `_lock` gate judge the item, not its row, // and they answer before this store read, as #22220 ordered. // - // Not asked of: a save that names a package (ADR-0048, its address is - // its own) or the package-unbound row (`packageId: null`, which the - // stored-metadata migration and the stored flow-credential move pass - // for a package-less row they rewrite); a draft save, whose binding the - // repository resolves (#11087). + // Asked of a publish-mode AUTHORING save whose `packageId` is absent or + // `null`, the two spellings of "no package" the request contract + // states. Not asked of: a save that names a package (ADR-0048, its + // address is its own); a draft save, whose binding the repository + // resolves (#11087); the server-stated rewrites of a row this store + // already holds (`source` set: the stored-metadata migration and the + // stored flow-credential move, which pass `null` for the + // package-unbound row they read and rewrite; `package-duplicate`, + // which always names its target), the same two the authoring rules + // above exempt. // // Several bindings is a name more than one package holds, and a save // naming none of them is refused, not guessed. - if (mode === 'publish' && request.packageId === undefined) { + if ( + mode === 'publish' + && request.packageId == null + && request.source === undefined + && request.writeFace !== 'package-duplicate' + ) { const writeType = PLURAL_TO_SINGULAR[request.type] ?? request.type; const address = await this.packagelessSaveAddress( writeType, request.name, itemDiscriminator(writeType, request.item), diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 6e2840b1dde..edb568d558a 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -713,11 +713,14 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'a Studio package workspace; a named read-only base package is ' + 'refused. On create the row is stamped with this id; on update an ' + 'existing binding is preserved, never silently re-bound. Absent or ' - + '`null` names no package: an `active` save writes the env-local ' - + 'overlay (no package stamp), and a `draft` save writes the draft of ' + + '`null` names no package. An `active` save then writes the row of the ' + + 'one package the item\'s active rows are bound to, as a save naming ' + + 'that package does, is refused `409 RESOURCE_CONFLICT` when they are ' + + 'bound to two or more, and with none writes the env-local overlay (no ' + + 'package stamp); a `draft` save writes the draft of ' + 'the package the item\'s active row is bound to when it is ' + 'package-bound (a pending change stays in the package it edits), else ' - + 'the env-local draft. That row is also the one the parent-version ' + + 'the env-local draft. The row written is also the one the parent-version ' + 'resolution reads: an unpinned save takes its version as the parent ' + '(last-write-wins), a pinned one is judged against it, and the item ' + 'read at the same scope serves it as `version`.', From 3b0118f85db1f31c8526540b0c1ba85906516926 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:10:14 +0000 Subject: [PATCH 06/12] fix(metadata-protocol): a delete naming no package takes the save door's address, and a delete can name the package it removes deleteMetaItem handed the repository no package, so its probe and its delete each took the first row a package-agnostic findOne met: one package's row of an item two packages hold, or the package's row when a package-less row sat beside it, with a receipt saying the item no longer exists. The delete now resolves its address through the same function as the direct save and the item read's version token (packagelessAddress, which gains the state it reads): named, that package's row; naming none, the one package the rows at the delete's state are bound to, the package-unbound row when none, and 409 RESOURCE_CONFLICT naming the packages when two or more. The receipt names the package it removed from and, when another row of the item is still stored, says so instead of claiming it gone. deletePackage and the package draft discards name the package they act for, so an uninstall or a discard never removes another package's row of the same name. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 263 ++++++++++++++++----- 1 file changed, 199 insertions(+), 64 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 024ecc29928..66d88b24a8a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1124,43 +1124,71 @@ function authoredLockFamilyRefusal( } /** - * [#23165, ADR-0048] The save door's refusal of a direct save that names no - * package, of an item whose active rows are bound to two or more packages. + * [#23165, ADR-0048] The refusal of a direct save, or a delete, that names no + * package, of an item whose rows at the address are bound to two or more + * packages ({@link ObjectStackProtocolImplementation.packagelessAddress}). * * A `sys_metadata` row is keyed `(org, type, name, package_id)`, so such a - * save has no one row to write. Before this refusal it wrote a package-less - * row beside the packages' rows and answered `200`, for a row the item read - * does not serve. Choosing one of the packages instead would be the - * package-agnostic `findOne`'s driver-order coin toss (#8907). The remedy is - * the request's: name the package, which is the address every other door of - * the item already takes (`?package=`, ADR-0048). + * write has no one row to write or remove. Before this refusal the save wrote + * a package-less row beside the packages' rows and answered `200`, for a row + * the item read does not serve, and the delete removed whichever row the + * package-agnostic `findOne` met first and answered "it no longer exists" + * while another package's row stayed served. Choosing one of the packages is + * that driver-order coin toss (#8907). The remedy is the request's: name the + * package, which is the address every door of the item takes (`?package=`, + * ADR-0048). * * `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member for * a refusal that comes from environment state rather than the body (the same - * body is accepted once the request names its package), and the code this + * request is accepted once it names its package), and the code this * package already answers when a name is held by more than one owner * (`importUnmanagedPackage`). The prescription first, because a 4xx message * crosses the REST boundary bounded at 500 characters with its tail * truncated; the candidates follow, sorted, so the message is the same on * every driver. */ -function packagelessSaveAmbiguityRefusal( +function packagelessAddressAmbiguityRefusal( + verb: 'save' | 'delete', type: string, name: string, packageIds: readonly string[], ): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } { const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); 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']; const err = new Error( - `Name the package this save is for, then send it again: on the REST door add ?package= with the package id, ` - + `in process pass packageId. Nothing was written. ${type} '${name}' belongs to ${packageIds.length} packages ` - + `(${shown}${more}), and a save that names none has no single row to write.`, + `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 ` + + `(${shown}${more}), and a ${verb} that names none has ${single}.`, ) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 }; err.code = 'RESOURCE_CONFLICT'; err.status = 409; return err; } +/** + * [#23165] The rows of an item a delete leaves stored at its address, in + * words, for the delete receipt: its rows bound to other packages (sorted, as + * {@link ObjectStackProtocolImplementation.packagelessAddress} answers them, + * at most five named) and its package-less row. The receipt names them + * instead of claiming the item gone or back at its artifact default. + */ +function remainingRowsPhrase(packageIds: readonly string[], unbound: boolean, noun: 'row' | 'draft'): string { + const parts: string[] = []; + if (packageIds.length > 0) { + const shown = packageIds.slice(0, 5).map((id) => `'${id}'`).join(', '); + const more = packageIds.length > 5 ? ` and ${packageIds.length - 5} more` : ''; + parts.push(packageIds.length === 1 + ? `its ${noun} bound to package ${shown}` + : `its ${noun}s bound to packages ${shown}${more}`); + } + if (unbound) parts.push(`its package-less ${noun}`); + const count = packageIds.length + (unbound ? 1 : 0); + return `${parts.join(' and ')} ${count === 1 ? 'remains' : 'remain'}`; +} + /** * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape * Studio's designer keys on to highlight the offending form control. @@ -18537,10 +18565,10 @@ export class ObjectStackProtocolImplementation implements * draft bound to the package of the item's active row when there is one * (#11087) — the address the save resolves, not the key it was handed. * [#23165] The active read that names no package likewise serves the token - * of the row a direct save naming none writes: the one package's row the - * item is bound to ({@link packagelessSaveAddress}). For an item several - * packages hold, that save is refused whatever it carries, and the token - * stays the package-unbound row's. + * of the row a direct save (or a delete) naming none writes: the one + * package's row the item is bound to ({@link packagelessAddress}). For an + * item several packages hold, that save and that delete are refused + * whatever they carry, and the token stays the package-unbound row's. */ private async readVersionToken( /** [#22877] `member` — the bundle member the read addressed; omitted, the canonical one. */ @@ -18554,64 +18582,73 @@ export class ObjectStackProtocolImplementation implements org: 'env', } as Parameters[0]; const packageId = request.packageId == null && state === 'active' - ? (await this.packagelessSaveAddress(ref.type, ref.name, request.member)).packageId + ? (await this.packagelessAddress(ref.type, ref.name, request.member)).packageId : request.packageId ?? null; const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); return stored === null ? null : this.receiptVersion(stored); } /** - * [#23165, ADR-0048] Where a DIRECT (active) save that names no package - * writes: the item's own package when its active rows are bound to exactly + * [#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 + * at `state`. The item's own package when those rows are bound to exactly * one, else the package-unbound row. `candidates` is every distinct - * binding, sorted; two or more is a name several packages hold, which - * {@link saveMetaItem} refuses ({@link packagelessSaveAmbiguityRefusal}) - * and for which `packageId` is `null`. - * - * The one resolution its two readers take, so they cannot disagree: the - * save door, which then writes as a save naming that package does, and - * {@link readVersionToken}, whose `version` a save at the same address - * accepts. - * - * Why the package's row: a save writes exactly one + * binding, sorted; two or more is a name several packages hold, which the + * doors refuse ({@link packagelessAddressAmbiguityRefusal}) and for which + * `packageId` is `null`. `unbound` says whether a package-unbound row sits + * beside them, which is what a delete of a package's row leaves served. + * + * 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. + * + * Why the package's row: a write addresses exactly one * `(org, type, name, package_id)` row, and the item read that names no * package serves the package's row whenever it is the item's only row. A * save that wrote the package-unbound row instead minted a second row - * beside it and answered `200` for a write that read never served. The - * draft save has resolved its address from the item's active row since - * #11087 (`SysMetadataRepository.resolveWriteHead`); the direct save did - * not, and this is that half. + * beside it and answered `200` for a write that read never served; a + * delete that took the first row a package-agnostic `findOne` met removed + * one package's row of an item two packages hold, or the package-less row + * beside a package's, and claimed the item gone. The draft save has + * resolved its address from the item's active row since #11087 + * (`SysMetadataRepository.resolveWriteHead`, unchanged); the direct save + * and the delete did not, and this is that half. * * The rows are the ones the item read chooses among * ({@link findServedOverlayRow}): the environment's (ADR-0131 D6), the - * active state, the canonical type the save writes under, the bundle - * member the save addresses (#22877), and no package predicate. They are - * read in full, never sampled: with two packages' rows at one address a - * package-agnostic `findOne` is a driver-order coin toss (#8907). A - * package-less row beside the package's row (minted by the defect this - * fixes) is not a second package, and stays as it is. An item with no - * binding keeps the package-unbound row, as before. An unprovisioned store - * holds no row; any other failed read propagates (#5532), because guessing - * the binding would choose the row the save lands in. - * - * ⛔ Not the repository's write-address default: `put` and `headAt` keep - * `PutOptions.packageId`'s documented one (omitted = the package-unbound - * row), because the callers that reach them with no package - * (`promoteDraft` and `restoreVersion` of a package-less row) mean that - * row, as do the server-stated rewrites the save door does not resolve - * for. The save door resolves, then names the package. + * state the write addresses, the canonical type, the bundle member + * (#22877), and no package predicate. They are read in full, never + * sampled: with two packages' rows at one address a package-agnostic + * `findOne` is a driver-order coin toss (#8907). A package-less row beside + * the package's row is not a second package, and stays as it is (no + * conversion: the card's existing-rows line). An item with no binding + * keeps the package-unbound row, as before. An unprovisioned store holds + * no row; any other failed read propagates (#5532), because guessing the + * binding would choose the row the write lands in. + * + * ⛔ Not the repository's write-address default: `put`, `headAt` and + * `delete` keep their documented ones (omitted = the package-unbound row + * for `put`/`headAt`, any row for `delete`), because the callers that reach + * them with no package (`promoteDraft` and `restoreVersion` of a + * package-less row) mean that row, as do the server-stated rewrites the + * save door does not resolve for. The doors resolve, then name the + * package. */ - private async packagelessSaveAddress( + private async packagelessAddress( type: string, name: string, - /** [#22877] The bundle member the save addresses (its body's); omitted, the canonical one. */ + /** [#22877] The bundle member the write addresses (a save's body's, a delete's selector); omitted, the canonical one. */ member?: string, - ): Promise<{ packageId: string | null; candidates: readonly string[] }> { + state: 'active' | 'draft' = 'active', + ): Promise<{ packageId: string | null; candidates: readonly string[]; unbound: boolean }> { let rows: Array<{ package_id?: unknown }> | null | undefined; try { // [#21911] The explicit system opt-in — see findServedOverlayRow. rows = await this.engine.find('sys_metadata', { - where: { type, name, state: 'active', organization_id: null, ...overlayMemberWhere(type, member) }, + where: { type, name, state, organization_id: null, ...overlayMemberWhere(type, member) }, context: { isSystem: true }, }); } catch (error) { @@ -18620,11 +18657,13 @@ export class ObjectStackProtocolImplementation implements this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } const bindings = new Set(); + let unbound = false; for (const row of rows ?? []) { if (typeof row?.package_id === 'string' && row.package_id !== '') bindings.add(row.package_id); + else unbound = true; } const candidates = [...bindings].sort(); - return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates }; + return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates, unbound }; } /** @@ -21087,7 +21126,7 @@ export class ObjectStackProtocolImplementation implements // [#23165, ADR-0048] THE WRITE ADDRESS of a direct save that names no // package: the item's own package when its active rows are bound to - // exactly one ({@link packagelessSaveAddress}). It used to be the + // exactly one ({@link packagelessAddress}). It used to be the // package-unbound row, always, so a save of a package's item minted a // second row beside the package's and answered `200` while the item // read kept serving the package's row. The draft save already resolved @@ -21126,11 +21165,11 @@ export class ObjectStackProtocolImplementation implements && request.writeFace !== 'package-duplicate' ) { const writeType = PLURAL_TO_SINGULAR[request.type] ?? request.type; - const address = await this.packagelessSaveAddress( + const address = await this.packagelessAddress( writeType, request.name, itemDiscriminator(writeType, request.item), ); if (address.candidates.length > 1) { - throw packagelessSaveAmbiguityRefusal(request.type, request.name, address.candidates); + throw packagelessAddressAmbiguityRefusal('save', request.type, request.name, address.candidates); } if (address.packageId !== null) request = { ...request, packageId: address.packageId }; } @@ -22069,7 +22108,7 @@ 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 packagelessSaveAddress}). A save into the + // ({@link packagelessAddress}). 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}]` @@ -24792,7 +24831,7 @@ export class ObjectStackProtocolImplementation implements * canonical member's was addressed for every draft of the name, so a * second locale's draft answered "no pending draft" and stayed. */ - draft: { type: string; name: string; member?: string }, + draft: { type: string; name: string; member?: string; packageId?: string | null }, actor: string | undefined, ): Promise { await this.deleteMetaItem({ @@ -24801,6 +24840,10 @@ export class ObjectStackProtocolImplementation implements state: 'draft', ...(actor ? { actor } : {}), ...(draft.member !== undefined ? { member: draft.member } : {}), + // [#23165, ADR-0048] The package the draft was listed under: the + // discard removes THAT package's draft, never another package's + // draft of the same name. + ...(draft.packageId !== undefined ? { packageId: draft.packageId } : {}), }); } @@ -25072,6 +25115,12 @@ export class ObjectStackProtocolImplementation implements type: row.type, name: row.name, state, + // [#23165, ADR-0048] The package being uninstalled + // owns this row, so the delete names it. Named no + // package, the delete of a name a second package also + // holds was a driver-order pick between the two rows + // (and is now refused). + packageId: request.packageId, ...(request.actor ? { actor: request.actor } : {}), ...(dropStorage ? { dropStorage: true } : {}), }); @@ -27484,6 +27533,16 @@ export class ObjectStackProtocolImplementation implements dropStorage?: boolean; /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ member?: string; + /** + * [#23165, ADR-0048] The package whose row this delete removes, as a + * save names the row it writes (`?package=` on the REST door). Named, + * the delete removes that package's row and no other. Absent or `null` + * names no package: the delete removes the row of the one package the + * 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}). + */ + packageId?: string | null; }): Promise<{ success: boolean; message?: string; @@ -27657,18 +27716,70 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: orgId ?? 'env', } as Parameters[0]; + // [#23165] The refusal of a package-less delete of an item two or + // more packages hold: the door's own verdict, rethrown as it is by + // the `catch` below, never re-wrapped as a delete that failed. + let addressRefusal: Error | undefined; try { const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; + // [#23165, ADR-0048] THE ADDRESS of the row this delete removes. + // It used to be no address at all: `repo.get` and `repo.delete` + // were handed no package, so each took the first row a + // package-agnostic `findOne` met — one package's row of an item + // two packages hold (the other package's row then served, and + // the receipt said "it no longer exists"), or the package's row + // when a package-less row sat beside it. + // + // Named, the delete removes that package's row and no other. + // Naming none, it takes the save door's resolution + // ({@link packagelessAddress}), read from the rows at the state + // it removes: the one package those rows are bound to, the + // package-unbound row when they are bound to none, and a + // refusal when they are bound to two or more. A draft discard + // reads the draft rows, so it removes the draft that exists, + // wherever the draft save put it (#11087). + // + // 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( + singularTypeForRepo, request.name, member, targetState, + ); + if (request.packageId == null && rowsAtAddress.candidates.length > 1) { + addressRefusal = packagelessAddressAmbiguityRefusal( + 'delete', request.type, request.name, rowsAtAddress.candidates, + ); + throw addressRefusal; + } + const packageId: string | null = request.packageId ?? 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, ...memberOption(member) }); + const current = await repo.get(ref, { state: targetState, packageId, ...memberOption(member) }); // [#21899] An origin-gated code-defined item with NO stored row: // the delete would remove nothing, and the item itself is not // removable at runtime — refused with the package door's own // verdict, the one its save gets. Rethrown as it is by the // `catch` below. if (!current && originGatedRefusal !== null) throw originGatedRefusal; + // [#23165] A delete NAMING a package that holds no row here, of + // an item whose other rows are stored: nothing is removed and + // those rows are still served, so neither the registry heal + // below nor its "already at artifact default" / "nothing to + // delete" claim holds. A delete naming none never lands here: + // it addresses a row the read above found. + if (!current && request.packageId != null && (rowsAtAddress.candidates.length > 0 || rowsAtAddress.unbound)) { + return { + success: true, + reset: false, + message: targetState === 'draft' + ? `No pending draft of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was discarded: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'draft')}.` + : `No row of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was deleted: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'row')}.`, + }; + } if (!current) { // Self-heal: even with no overlay row, a stale runtime // shadow may linger in the registry (e.g. pollution from @@ -27729,6 +27840,10 @@ export class ObjectStackProtocolImplementation implements ? 'override-artifact' : 'runtime-only', state: targetState, + // [#23165] The row the probe read, by its key — never the + // repository's "any package" match, which takes the first + // row it meets. + packageId, ...memberOption(member), }); @@ -27785,6 +27900,12 @@ export class ObjectStackProtocolImplementation implements organizationId: orgId, ...mutationEventMember(singularTypeForRepo, member), }); + // [#23165] What is still stored at this address once this row + // is gone, from the rows the address read already holds. + const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId); + const unboundRemains = rowsAtAddress.unbound && packageId !== null; + const remains = remainingPackages.length > 0 || unboundRemains; + const deletedPackage = packageId !== null ? ` (package=${packageId})` : ''; return { success: true, reset: true, @@ -27809,17 +27930,31 @@ export class ObjectStackProtocolImplementation implements // The draft leg discards a pending draft and never claimed // a reset, so it is unchanged. `[seq=…]` stays on every // branch — HMR cursors read it. + // + // [#23165] Both active sentences claim what is left once + // this row is gone, and that is true only when no other row + // of the item is stored at this address: a delete of one + // package's row leaves another package's row, or the + // package-less row beside it, stored and served. Then the + // receipt names what remains instead. A delete of a + // package's row names the package, as the save receipt + // does; a delete of the package-unbound row with nothing + // left reads byte for byte as before. message: (request.state === 'draft') ? `Draft discarded — ${request.type}/${request.name}. [seq=${result.seq}]` : artifactBacked - ? `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]` - : `Deleted ${singularTypeForRepo} '${request.name}' — it no longer exists. [seq=${result.seq}]`, + ? (remains + ? `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} still carries another overlay: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]` + : `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`) + : (remains + ? `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage}, and it still exists: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]` + : `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage} — it no longer exists. [seq=${result.seq}]`), }; } catch (err: any) { // [#21899] The door's own verdict, not a store failure: it // leaves with its envelope and sentence, never re-wrapped as an // overlay delete that failed. - if (err === originGatedRefusal) throw err; + if (err === originGatedRefusal || (addressRefusal !== undefined && err === addressRefusal)) throw err; if (err instanceof ConflictError) { // [#21207] Keyed values or none in the text and attributes. const conflict = await this.metadataConflictRefusal( From ddf0177efeb8bf8833c94a43f2773b08779dab29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:29:42 +0000 Subject: [PATCH 07/12] feat(rest,spec): the metadata reset door names the package whose row it removes; pins for the delete door's address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /meta/:type/:name reads ?package= through metaItemPackageBinding, as the save, publish and read doors of the item do, and passes it as packageId; `package` joins the door's closed query set and its repeated parameter guard. DeleteMetaItemRequestSchema declares the optional packageId, and its response docblock no longer claims every reset leaves the artifact default. The protocol pins cover the delete -> read round trip, the version token a delete accepts, the two-package refusal on the delete and the draft discard, a named delete leaving the other package's row served, an uninstall and a draft discard that remove only their package's row, and the unbound and residue controls. The changeset reads Clause-② yes and covers both doors. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .../23165-direct-save-resolves-package.md | 31 ++-- .../protocol.packageless-save-address.test.ts | 173 ++++++++++++++++++ .../rest-server-meta-member-selector.test.ts | 23 ++- packages/rest/src/rest-server.ts | 14 +- packages/spec/src/api/protocol.test.ts | 11 ++ packages/spec/src/api/protocol.zod.ts | 19 +- 6 files changed, 251 insertions(+), 20 deletions(-) diff --git a/.changeset/23165-direct-save-resolves-package.md b/.changeset/23165-direct-save-resolves-package.md index 71fac89e6c2..c33759778b2 100644 --- a/.changeset/23165-direct-save-resolves-package.md +++ b/.changeset/23165-direct-save-resolves-package.md @@ -1,32 +1,35 @@ --- '@objectstack/metadata-protocol': minor -'@objectstack/spec': patch +'@objectstack/spec': minor +'@objectstack/rest': minor --- -fix(metadata-protocol)!: a direct metadata save that names no package writes the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`, instead of answering `200` for a package-less row the item read never serves (ADR-0048) +fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its package (ADR-0048, ADR-0070) -Clause-②: no (narrowing) +Clause-②: yes (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 one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. The direct save did not. +A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. -**FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. The item read that names no package served `version: null` for such an item, as if a save would create it. +**FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. -**TO.** A direct save that names no package resolves its address from the item's active rows: +**TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): -- **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next. The receipt names the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)". Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save with the served `If-Match` is accepted. -- **Bound to two or more packages.** The save is refused `409 RESOURCE_CONFLICT` and writes nothing. The message names the packages and the remedy: name one with `?package=`. -- **Bound to none.** The save writes the package-unbound row, as before, and its receipt reads as before. +- **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. +- **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. +- **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. + +The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. **Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. -`packageId: null` in process names no package too, as the request contract states. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. +`packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. -**Unchanged.** A save that names a package. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted or deleted. +**Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. -**Remedy.** For an item two packages hold, send the save with `?package=` naming the package whose row it edits. A save receipt now names the package when the save wrote a package's row; a client that compares the receipt text should expect `, package=` after `env-wide`. +**Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. -`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay. Description only; the schema is unchanged. +`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). 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 fe2e8c9d823..9dc06e0a77d 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -422,3 +422,176 @@ describe('an item a CODE package ships', () => { expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Unnamed'); }); }); + +/** + * [#23165, ADR-0048] The DELETE door takes the same address. + * + * `deleteMetaItem` handed the repository no package, so its probe and its + * delete each took the first row a package-agnostic `findOne` met: one + * package's row of an item two packages hold (the other package's row then + * served, while the receipt said "it no longer exists"), or the package's row + * when a package-less row sat beside it. Named, a delete now removes that + * package's row and no other; naming none, it resolves through the save + * door's function: one binding removes that row, two or more are refused, + * none removes the package-unbound row as before. + */ +describe('a delete that names no package removes the row the save door addresses', () => { + it('the round trip: a package-less delete of an item ONE package owns removes that row, and the read serves nothing', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'deleted_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(receipt).toMatchObject({ success: true, reset: true }); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain('it no longer exists'); + expect(rows('app', name)).toEqual([]); + const read = await protocol.getMetaItem({ type: 'app', name }).catch((e: unknown) => e); + expect(read?.item ?? null).toBeNull(); + }); + + it('the item read\'s version names the row that delete removes, so read → delete with the served token is accepted', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'pinned_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + const { version } = await protocol.getMetaItem({ type: 'app', name }); + expect(version).toEqual(expect.any(String)); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, parentVersion: version }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name)).toEqual([]); + }); + + it('a draft discard naming no package removes the draft the package-less draft save put in the package', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'draft_discard_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Pending' }), mode: 'draft' }); + expect(rows('app', name, 'draft').map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, state: 'draft' }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name, 'draft')).toEqual([]); + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([WRITABLE]); + }); +}); + +describe('an item TWO packages hold: a delete naming neither is refused', () => { + async function twoPackageItem(name: string) { + const fixture = makeProtocol(); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'A' }), packageId: WRITABLE }); + await fixture.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'B' }), packageId: OTHER_WRITABLE }); + return fixture; + } + + it('409 RESOURCE_CONFLICT naming both packages, and nothing is deleted', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + const bodiesBefore = bodiesByBinding(rows('app', name)); + + const err = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('RESOURCE_CONFLICT'); + expect(err.message).toContain(`'${WRITABLE}'`); + expect(err.message).toContain(`'${OTHER_WRITABLE}'`); + expect(err.message).toContain('?package='); + expect(bodiesByBinding(rows('app', name))).toEqual(bodiesBefore); + }); + + it('a draft discard naming no package is refused when drafts sit in both packages', async () => { + const name = 'shared_draft_app'; + const { protocol, rows } = await twoPackageItem(name); + for (const packageId of [WRITABLE, OTHER_WRITABLE]) { + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Draft ${packageId}` }), packageId, mode: 'draft' }); + } + + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name, state: 'draft' }))); + + expect(envelope).toMatchObject({ status: 409, code: 'RESOURCE_CONFLICT' }); + expect(rows('app', name, 'draft')).toHaveLength(2); + }); + + it('naming one removes that package\'s row only, the other is served, and the receipt names what remains', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, packageId: WRITABLE }); + + expect(receipt.reset).toBe(true); + expect(rows('app', name).map((r) => r.package_id)).toEqual([OTHER_WRITABLE]); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain(`'${OTHER_WRITABLE}'`); + expect(receipt.message).not.toContain('no longer exists'); + expect((await protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ label: 'B', _packageId: OTHER_WRITABLE }); + }); + + it('naming a package that holds no row removes nothing and says the other rows remain', async () => { + const name = 'shared_delete_app'; + const { protocol, rows } = await twoPackageItem(name); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name, packageId: 'com.qa.thirdapp' }); + + expect(receipt).toMatchObject({ success: true, reset: false }); + expect(receipt.message).toContain(`'${WRITABLE}'`); + expect(receipt.message).toContain(`'${OTHER_WRITABLE}'`); + expect(receipt.message).not.toContain('nothing to delete'); + expect(rows('app', name)).toHaveLength(2); + }); + + it('uninstalling one package removes its row, never the other package\'s', async () => { + const name = 'shared_uninstall_app'; + const { protocol, rows } = await twoPackageItem(name); + + const out = await protocol.deletePackage({ packageId: OTHER_WRITABLE, keepData: true }); + + expect(out.failed ?? []).toEqual([]); + expect(rows('app', name).map((r) => r.package_id)).toEqual([WRITABLE]); + expect(bodiesByBinding(rows('app', name))[WRITABLE]).toMatchObject({ label: 'A' }); + }); + + it('discarding one package\'s drafts removes its draft, never the other package\'s', async () => { + const name = 'shared_discard_app'; + const { protocol, rows } = await twoPackageItem(name); + for (const packageId of [WRITABLE, OTHER_WRITABLE]) { + await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Draft ${packageId}` }), packageId, mode: 'draft' }); + } + + const out = await protocol.discardPackageDrafts({ packageId: OTHER_WRITABLE }); + + expect(out).toMatchObject({ discardedCount: 1, failedCount: 0 }); + expect(rows('app', name, 'draft').map((r) => r.package_id)).toEqual([WRITABLE]); + }); +}); + +describe('CONTROL: what the delete resolution leaves alone', () => { + it('an item with no package binding: the package-unbound row is removed, and the receipt reads as before', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'plain_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name) }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(receipt.message).toBe(`Deleted app '${name}' — it no longer exists. [seq=${receipt.seq}]`); + expect(rows('app', name)).toEqual([]); + }); + + it('a package-less row beside the package\'s row stays: the delete removes the package\'s row and says the other remains', async () => { + const { protocol, rows, seedRow } = makeProtocol(); + const name = 'residue_delete_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), packageId: WRITABLE }); + seedRow({ type: 'app', name, package_id: null, metadata: JSON.stringify(app(name, { active: false })), checksum: 'sha-residue' }); + + const receipt = await protocol.deleteMetaItem({ type: 'app', name }); + + expect(rows('app', name).map((r) => r.package_id ?? null)).toEqual([null]); + expect(receipt.message).toContain(`package=${WRITABLE}`); + expect(receipt.message).toContain('package-less row'); + expect(receipt.message).not.toContain('no longer exists'); + expect((await protocol.getMetaItem({ type: 'app', name })).item).toMatchObject({ active: false }); + }); +}); diff --git a/packages/rest/src/rest-server-meta-member-selector.test.ts b/packages/rest/src/rest-server-meta-member-selector.test.ts index b08123657ca..6b52157c4da 100644 --- a/packages/rest/src/rest-server-meta-member-selector.test.ts +++ b/packages/rest/src/rest-server-meta-member-selector.test.ts @@ -143,7 +143,7 @@ describe('[#22877] each by-name /meta door declares a closed query set, the memb it('the measured sets — paging, switches and the read a frame down included', () => { expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); - expect([...META_ITEM_RESET_PARAMS].sort()).toEqual(['dropStorage', 'member', 'state']); + expect([...META_ITEM_RESET_PARAMS].sort()).toEqual(['dropStorage', 'member', 'package', 'state']); expect([...META_ITEM_HISTORY_PARAMS].sort()).toEqual(['limit', 'member', 'sinceSeq']); expect([...META_ITEM_PUBLISH_PARAMS].sort()).toEqual(['member', 'package']); expect([...META_ITEM_ROLLBACK_PARAMS].sort()).toEqual(['member', 'toVersion']); @@ -209,6 +209,27 @@ describe('[#22877] PRESERVATION — the request each protocol door receives', () }); }); + it('[#23165] DELETE item: `?package=` reaches `deleteMetaItem` as `packageId`; `all` and an empty value name none', async () => { + for (const [raw, packageId] of [['com.acme.mail', 'com.acme.mail'], ['all', undefined], ['', undefined]] as const) { + const { protocol, drive } = boot(); + const answer = await drive('DELETE', ITEM, { package: raw }); + expect(answer.status, `${raw}: ${JSON.stringify(answer.body)}`).toBe(200); + expect(protocol.deleteMetaItem.mock.calls[0][0], raw).toEqual({ + type: 'email_template', name: 'acme.welcome', actor: 'u1', + ...(packageId !== undefined ? { packageId } : {}), + }); + } + }); + + it('[#23165] DELETE item: REFUSAL — a repeated `?package=` is a located 400 and the protocol is never called', async () => { + const { protocol, drive } = boot(); + const answer = await drive('DELETE', ITEM, { package: ['com.acme.mail', 'com.acme.other'] }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"package" query parameter was supplied 2 times'); + expect(protocol.deleteMetaItem).not.toHaveBeenCalled(); + }); + it('GET /history: the member reaches `historyMetaItem` beside the paging pair', async () => { const { protocol, drive } = boot(); const answer = await drive('GET', `${ITEM}/history`, { member: 'zh-CN', sinceSeq: '3', limit: '10' }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9a64fac24d3..78f538b469f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -672,9 +672,12 @@ export const META_ITEM_READ_PARAMS: readonly string[] = [ * [#22877] The closed set of `DELETE {metaPath}/:type/:name` (the reset). * **Measured**: `state` (the draft discard) and `dropStorage` (the opt-in * table teardown); the OCC pin and the actor ride headers and the session. - * Plus {@link META_ITEM_MEMBER_PARAM}. + * Plus {@link META_ITEM_MEMBER_PARAM}. [#23165, ADR-0048] Plus `package`, the + * row the delete removes, read through {@link metaItemPackageBinding} as the + * other doors of the item read it: a delete naming no package of an item two + * packages hold is refused `409`, and naming the package is its remedy. */ -export const META_ITEM_RESET_PARAMS: readonly string[] = ['state', 'dropStorage', META_ITEM_MEMBER_PARAM]; +export const META_ITEM_RESET_PARAMS: readonly string[] = ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM]; /** * [#22877] The closed set of `GET {metaPath}/:type/:name/history`. @@ -7363,7 +7366,7 @@ export class RestServer { // is not what the request asked for. // [#22877] Recognition first, over the measured closed set. if (refuseUnknownQueryParams(req, res, META_ITEM_RESET_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', META_ITEM_MEMBER_PARAM])) return; + if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', 'package', META_ITEM_MEMBER_PARAM])) return; const stateParam = typeof req.query?.state === 'string' && req.query.state.toLowerCase() === 'draft' ? 'draft' as const @@ -7374,6 +7377,7 @@ export class RestServer { // previewed object" flow so a publish-to-preview leaves no // orphan table. Destructive — opt-in, defaults off. const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; + const deletePackageId = metaItemPackageBinding(req.query?.package); // [ADR-0131 D6] No organization, as on the `PUT` twin: the // reset reaches the environment-wide row. @@ -7403,6 +7407,10 @@ export class RestServer { ...(dropStorage ? { dropStorage: true } : {}), // [#22877] Resets ONE member of a bundled type. ...metaMemberSelector(req.query), + // [#23165, ADR-0048] The package whose row the delete + // removes; `all` and an empty value name none, as on + // the save door, and the protocol then resolves it. + ...(deletePackageId ? { packageId: deletePackageId } : {}), }; const result = await p.deleteMetaItem(deleteRequest); res.json(result); diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 37dd7c8f0ec..2917b335ffc 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2263,6 +2263,7 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do actor: 'admin@objectos.ai', state: 'draft', dropStorage: true, + packageId: 'com.acme.crm', }; const result = DeleteMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); @@ -2271,6 +2272,15 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do } }); + it('[ADR-0048] packageId names the row the delete removes; null names no package, as on the save', () => { + for (const packageId of ['com.acme.crm', null] as const) { + const result = DeleteMetaItemRequestSchema.safeParse({ ...base, packageId }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.packageId).toBe(packageId); + } + expect(DeleteMetaItemRequestSchema.safeParse({ ...base, packageId: 42 }).success).toBe(false); + }); + it('requires type AND name — the reset addresses one item', () => { expect(DeleteMetaItemRequestSchema.safeParse(base).success).toBe(true); expect(DeleteMetaItemRequestSchema.safeParse({ type: 'view' }).success).toBe(false); @@ -2348,6 +2358,7 @@ describe('MetadataProtocol.deleteMetaItem types against the caught-up request sc actor: 'admin', state: 'draft', dropStorage: true, + packageId: 'com.acme.crm', }; expect(good.type).toBe('view'); // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member; the REST door layers it on via TransportScopedMetaRequest. diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index edb568d558a..ef0dc2a2b94 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1384,6 +1384,19 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ + 'object" flow so a publish-to-preview leaves no orphan table.', ), member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), + packageId: z.string().nullable().optional().describe( + '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.', + ), })); /** @@ -1391,8 +1404,10 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ * * Describes the FULL body `DELETE /api/v1/meta/:type/:name` returns (#13155 — * the #5745 ruling carried to the third verb on this door). `reset === true` - * means a row was deleted (item is now at artifact default); `reset === false` - * means no overlay row existed (already at artifact default). + * means a row was deleted; `reset === false` means the delete's address held + * no row, so nothing was removed. Either way the item is at its artifact + * default only when no other row of it is still stored, and the `message` + * names any that is (ADR-0048: one row per package). * * **Why the widening.** The metadata door has three write verbs, all running * the same ADR-0094 mutation projector and the same history append, and all From 65b3a4db993dc689441b625da72465ec276b212c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:32:43 +0000 Subject: [PATCH 08/12] test(metadata-protocol): the engine-double ledger records the package-less address pins' pinned double Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d89784d4801..ca84b36b564 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1121,6 +1121,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts", "verb": "delete", From 389e545a4b8a7052ee89cf3a89b2452591113285 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:10:39 +0000 Subject: [PATCH 09/12] docs(spec): regenerate the protocol reference and the authorable surface for the save and delete packageId descriptions Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 3 ++- packages/spec/authorable-surface/api.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ae267b96749..b15cd76bbc7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -729,6 +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. | --- @@ -2622,7 +2623,7 @@ Installed package with runtime lifecycle state | **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. | | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | -| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package: an `active` save writes the env-local overlay (no package stamp), and a `draft` save writes the draft of the package the item's active row is bound to when it is package-bound (a pending change stays in the package it edits), else the env-local draft. That row is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package. An `active` save then writes the row of the one package the item's active rows are bound to, as a save naming that package does, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none writes the env-local overlay (no package stamp); a `draft` save writes the draft of the package the item's active row is bound to when it is package-bound (a pending change stays in the package it edits), else the env-local draft. The row written is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | | **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index d29a7e616fc..aad9e323e64 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -498,6 +498,7 @@ "api/DeleteMetaItemRequest:dropStorage", "api/DeleteMetaItemRequest:member", "api/DeleteMetaItemRequest:name", + "api/DeleteMetaItemRequest:packageId", "api/DeleteMetaItemRequest:parentVersion", "api/DeleteMetaItemRequest:state", "api/DeleteMetaItemRequest:type", From 13f2d427682db80ca4719b77423cc4cd7ea99e7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:25:54 +0000 Subject: [PATCH 10/12] test(rest): the package-less save and delete on the real routes; a #22128 rider case that pinned the minted second row is replaced The rider case "no row at the env-local address: the read serves null, and If-None-Match: * creates it" pinned a package-less save of a package-owned item minting an env-local row beside the package's row, which is the defect this change removes. It is replaced by the same invariant at the address the save now resolves: the read's token is the package row's, the create assertion is refused because that row is there, and If-Match writes it. New real-route pins (RestServer, better-sqlite3): the card's probe through PUT, GET and DELETE; two packages refused 409 RESOURCE_CONFLICT on both doors with nothing changed, and DELETE ?package= removing that package's row only; the unbound control. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- ...eta-draft-head-package-inheritance.test.ts | 88 +++++++++++++++++-- 1 file changed, 79 insertions(+), 9 deletions(-) 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 46512cd8029..c463e3291d4 100644 --- a/packages/rest/src/meta-draft-head-package-inheritance.test.ts +++ b/packages/rest/src/meta-draft-head-package-inheritance.test.ts @@ -167,7 +167,11 @@ async function boot(opts: { item?: { type: string; name: string }; permissions?: }, { context: { isSystem: true } } as any); }; - return { read, save, stored, storedBodies, seed }; + /** `DELETE /meta/view/case_grid` (the reset). */ + const remove = (query: Record = {}, headers: Record = {}) => + call('DELETE', `${META}/:type/:name`, { path, params: item, query, headers }); + + return { read, save, remove, stored, storedBodies, seed }; } const ok = (res: any) => { @@ -263,17 +267,23 @@ describe('[#22128] `?package=all`: the read resolves `all` the way the save does expect(await h.stored('active')).toEqual([{ label: 'edited', packageId: null }]); }, 60_000); - it('active, no row at the env-local address: the read serves `null`, and `If-None-Match: *` creates it', async () => { + // [#23165] Replaced, not respelled: this case pinned the read serving + // `null` and `If-None-Match: *` minting an env-local row BESIDE the + // package's row, which is the defect #23165 removes (a package-less save + // of an item a package owns writes that package's row). The invariant the + // rider asked for still holds and is what is pinned: `all` names no + // package on both doors, so the read's token is the one the save compares + // against, and the save writes the row the read serves. + it('active, an item a package owns: the read serves that package row\'s `version`, and the save naming no package addresses the same row', async () => { const h = await boot(); ok(await h.save(view('live'), { package: PKG })); - expect(ok(await h.read({ package: 'all' })).version).toBeNull(); - const created = ok(await h.save(view('env-local'), { package: 'all' }, { 'if-none-match': '*' })); - expect(ok(await h.read({ package: 'all' })).version).toBe(created.version); - expect(await h.stored('active')).toEqual(expect.arrayContaining([ - { label: 'live', packageId: PKG }, - { label: 'env-local', packageId: null }, - ])); + const token = ok(await h.read({ package: 'all' })).version; + expect(token).toMatch(KEYED); + const refused = refusedConflict(await h.save(view('as if first'), { package: 'all' }, { 'if-none-match': '*' })); + expect(refused.currentVersion).toBe(token); + ok(await h.save(view('edited'), { package: 'all' }, { 'if-match': token })); + expect(await h.stored('active')).toEqual([{ label: 'edited', packageId: PKG }]); }, 60_000); it('draft of a package-owned item: `?state=draft&package=all` serves the inherited draft and its token, and the save accepts it', async () => { @@ -350,3 +360,63 @@ describe('[#22128] the credential carry-forward reads the row the draft save ove expect(drafts.map((r) => [r.body.label, r.packageId, holdsCredential(r.body)])).toEqual([['draft 2', null, true]]); }, 60_000); }); + +/** + * [#23165, ADR-0048] The direct save and the delete that name no package, on + * the real routes: they address the row of the one package the item belongs + * to, a name two packages hold is refused, and `?package=` on the delete door + * removes that package's row. The protocol suite + * (`protocol.packageless-save-address.test.ts` in metadata-protocol) holds the + * rest of the matrix over a stub engine; these drive the HTTP doors over + * better-sqlite3. + */ +describe('[#23165] a save or a delete naming no package addresses the one package\'s row, on the real routes', () => { + const OTHER_PKG = 'com.probe.other'; + + /** A refusal of a write naming no package, of a name two packages hold. */ + const refusedAmbiguous = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe('RESOURCE_CONFLICT'); + return res.body; + }; + + it('the card\'s probe: the package-less save writes the package\'s row and the read serves it; the package-less delete removes that row', async () => { + const h = await boot(); + ok(await h.save(view('live'), { package: PKG })); + + const saved = ok(await h.save(view('disabled'))); + expect(saved.message).toContain(`package=${PKG}`); + expect(await h.stored('active')).toEqual([{ label: 'disabled', packageId: PKG }]); + expect(ok(await h.read()).item.label).toBe('disabled'); + + const deleted = ok(await h.remove()); + expect(deleted.reset).toBe(true); + expect(await h.stored('active')).toEqual([]); + }, 60_000); + + it('two packages: the package-less save and delete are refused 409 and change nothing; `?package=` on the delete removes that row only', async () => { + const h = await boot(); + ok(await h.save(view('A'), { package: PKG })); + ok(await h.save(view('B'), { package: OTHER_PKG })); + const both = [{ label: 'A', packageId: PKG }, { label: 'B', packageId: OTHER_PKG }]; + + expect(refusedAmbiguous(await h.save(view('neither'))).error).toContain(OTHER_PKG); + expect(refusedAmbiguous(await h.remove()).error).toContain(OTHER_PKG); + expect(await h.stored('active')).toEqual(expect.arrayContaining(both)); + expect(await h.stored('active')).toHaveLength(2); + + const deleted = ok(await h.remove({ package: PKG })); + expect(deleted.reset).toBe(true); + expect(deleted.message).toContain(OTHER_PKG); + expect(await h.stored('active')).toEqual([{ label: 'B', packageId: OTHER_PKG }]); + expect(ok(await h.read()).item.label).toBe('B'); + }, 60_000); + + it('control: an item with no package binding is saved and deleted at the package-unbound row, as before', async () => { + const h = await boot(); + ok(await h.save(view('plain'))); + expect(await h.stored('active')).toEqual([{ label: 'plain', packageId: null }]); + expect(ok(await h.remove()).reset).toBe(true); + expect(await h.stored('active')).toEqual([]); + }, 60_000); +}); From e9b1744efa039a0522d668dfec7586e157092dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:22:42 +0000 Subject: [PATCH 11/12] test(metadata-protocol): two delete-path doubles store package_id as a store does, and answer the item rows read protocol.delete-receipt-wording and protocol.delete-object-registry-unregister built rows with no package_id key and a strict matcher, which worked only while the delete named no package. The delete now addresses the unbound row as package_id NULL (ADR-0048), which is the shape every stored row has, so the fixture rows carry package_id: null, and find answers sys_metadata from the rows (the delete reads the item's rows to resolve its address; the history table the counters read stays empty). No assertion changed. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- ...col.delete-object-registry-unregister.test.ts | 10 +++++++++- .../src/protocol.delete-receipt-wording.test.ts | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts index 301a34de629..643fa229bce 100644 --- a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts @@ -45,6 +45,7 @@ interface Row { name: string; organization_id: string | null; state: string; + package_id: string | null; checksum: string; metadata: string; } @@ -56,6 +57,9 @@ const overlayRow = (type: string, name: string): Row => ({ type, name, organization_id: null, + // [#23165] Stored as the store holds an unbound row: the column is there, + // NULL. The delete now addresses its row by package (ADR-0048). + package_id: null, state: 'active', checksum: 'sha256:stored-head', metadata: JSON.stringify({ name, label: 'Stored' }), @@ -133,7 +137,11 @@ function makeHarness(opts: { if (table !== 'sys_metadata') return null; return rows.find((r) => matches(r, query.where)) ?? null; }, - async find() { return []; }, + // [#23165] `sys_metadata` answers from the rows: the delete reads the + // item's rows to resolve its address. Every other table is empty. + async find(table: string, query: { where?: Record } = {}) { + return table === 'sys_metadata' ? rows.filter((r) => matches(r, query.where)) : []; + }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data }; }, async update(_table: string, data: Record, options?: Record) { assertEngineUpdateDispatch(data, options); diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts index bb1cb246361..e7969b9a9d6 100644 --- a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -57,6 +57,7 @@ interface Row { name: string; organization_id: string | null; state: string; + package_id: string | null; checksum: string; metadata: string; } @@ -69,6 +70,10 @@ function overlayRow(type: string, name: string, state: 'active' | 'draft' = 'act type, name, organization_id: null, + // [#23165] Stored as the store holds an unbound row: the column is + // there, NULL. The delete now addresses its row by package (ADR-0048), + // so a row with no `package_id` key is a shape no store produces. + package_id: null, state, checksum: 'sha256:stored-head', metadata: JSON.stringify({ name, label: 'Stored' }), @@ -82,7 +87,7 @@ function makeStubEngine(opts: { const rows = [...(opts.rows ?? [])]; const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`)); /** Match only on the keys the caller actually constrained (`whereFor` */ - /** omits `state` on the legacy path and `package_id` on delete). */ + /** omits `state` on the legacy path). */ const matches = (row: Row, where: Record = {}) => Object.entries(where).every(([k, v]) => (row as any)[k] === v); @@ -92,8 +97,13 @@ function makeStubEngine(opts: { if (table !== 'sys_metadata') return null; return rows.find((r) => matches(r, query.where)) ?? null; }, - // History counters (`nextItemVersion` / `nextEventSeq`) read here. - async find() { return []; }, + // History counters (`nextItemVersion` / `nextEventSeq`) read here, + // from `sys_metadata_history`, which holds nothing in this double. + // [#23165] `sys_metadata` answers from the rows: the delete reads the + // item's rows to resolve its address. + async find(table: string, query: { where?: Record } = {}) { + return table === 'sys_metadata' ? rows.filter((r) => matches(r, query.where)) : []; + }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data }; }, From b546c5ff20e1c8b8b3f5786af4e2f340175ed871 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:50:12 +0000 Subject: [PATCH 12/12] test(metadata-protocol): the two delete-path doubles' sys_metadata find holds the caller's limit Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .../src/protocol.delete-object-registry-unregister.test.ts | 6 ++++-- .../src/protocol.delete-receipt-wording.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts index 643fa229bce..26642a8a6a4 100644 --- a/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-object-registry-unregister.test.ts @@ -139,8 +139,10 @@ function makeHarness(opts: { }, // [#23165] `sys_metadata` answers from the rows: the delete reads the // item's rows to resolve its address. Every other table is empty. - async find(table: string, query: { where?: Record } = {}) { - return table === 'sys_metadata' ? rows.filter((r) => matches(r, query.where)) : []; + async find(table: string, query: { where?: Record; limit?: number } = {}) { + if (table !== 'sys_metadata') return []; + const hit = rows.filter((r) => matches(r, query.where)); + return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data }; }, async update(_table: string, data: Record, options?: Record) { diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts index e7969b9a9d6..2e9e73fdbfb 100644 --- a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -101,8 +101,10 @@ function makeStubEngine(opts: { // from `sys_metadata_history`, which holds nothing in this double. // [#23165] `sys_metadata` answers from the rows: the delete reads the // item's rows to resolve its address. - async find(table: string, query: { where?: Record } = {}) { - return table === 'sys_metadata' ? rows.filter((r) => matches(r, query.where)) : []; + async find(table: string, query: { where?: Record; limit?: number } = {}) { + if (table !== 'sys_metadata') return []; + const hit = rows.filter((r) => matches(r, query.where)); + return typeof query.limit === 'number' ? hit.slice(0, query.limit) : hit; }, async insert(_table: string, data: Record) { return { id: 'inserted', ...data };