diff --git a/.changeset/23712-draft-save-d99-address.md b/.changeset/23712-draft-save-d99-address.md new file mode 100644 index 0000000000..0a9253f04e --- /dev/null +++ b/.changeset/23712-draft-save-d99-address.md @@ -0,0 +1,30 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a draft save naming no package of an object its package-less definition owns is refused when its draft address is a package's draft, with a way out to the package-less draft (ADR-0029 D9.9) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The ADR-0029 D9.9 check of the object save door is now asked of the package the write lands in. A draft save naming no package (`PUT /api/v1/meta/object/NAME?mode=draft`, Studio's designer save) leaves the binding to the draft address, which is the item's one package `Q` when an active row is bound to `Q`, or `Q`'s draft when no active row is bound to a package and `Q`'s draft is the only one stored (ADR-0048). The check used to ask with the request's empty package, so an object whose owner is its package-less definition read as its own re-save, and the save wrote `Q`'s draft. It answered `200`, and the promote door then refused every publish of that draft. + +What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written, carrying `packageId: Q` and `ownerPackageId: null`. Only rows written before the save door refused a package's row beside the package-less one, or drafts bound to a package stored before that row existed, reach these states. + +- Over a row bound to `Q` stored beside the package-less one, with no draft, with a draft bound to no package, or with both that draft and `Q`'s: FROM `PUT /api/v1/meta/object/NAME?mode=draft` answering `200` with `Q`'s draft written (a new one, or the draft bound to no package adopted into `Q`), TO the refusal "A draft save naming no package of object 'NAME' writes a draft bound to 'Q', which no publish can serve: the object is served by its package-less definition. First DELETE /api/v1/meta/object/NAME?package=Q, then send this save again: it writes the package-less draft. Nothing was written." +- The same row with `Q`'s draft and no draft bound to no package: FROM `200` into `Q`'s draft, TO the refusal naming `Q`'s draft discard after the row's delete: "First DELETE /api/v1/meta/object/NAME?package=Q, then ?state=draft&package=Q (the draft discard), then send this save again: …". Deleting the row alone leaves `Q`'s draft, which the address then still names. Each package's draft is named the same way when several are stored. +- No row bound to a package, and `Q`'s draft the only draft stored: FROM `200` into `Q`'s draft, TO the refusal "First DELETE /api/v1/meta/object/NAME?state=draft&package=Q (the draft discard), then send this save again: it writes the package-less draft. …". + +**Remedy, in one line.** Send the requests the refusal names, in its order, then the same draft save: it writes the package-less draft, and the table, its records and the served definition stay as they are. A discard removes only that package's draft, which no publish could serve; the save sent again carries the edit. To move the object into `Q` instead, send the draft save naming `Q`: its refusal names that move. + +**Unchanged.** + +- A draft save naming no package where no package's row and no package's draft is stored: it writes the draft bound to no package. +- A draft save naming no package where the draft bound to no package is the address (no package's row, that draft stored): it is written, as before. +- A draft save naming no package over rows bound to two or more packages, or over two packages' drafts with no package row and no draft bound to no package: `409 RESOURCE_CONFLICT`, as before. +- Every save naming a package, every publish-mode save, and the promote doors: same answers and texts. +- An object a code package ships, an object owned by a runtime row bound to a real package, and every type except `object`. +- Stored rows are not rewritten on upgrade. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3f21089487..f602b0e100 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2158,6 +2158,14 @@ interface UnboundOwnerRefusalState { * definition, and one naming `P` finds no `P` draft (`NO_DRAFT`). */ adoptedDraft?: boolean; + /** + * [#23712] A DRAFT save naming no package (with `namedNoPackage`): the + * packages whose drafts of the object are stored with no package-unbound + * draft beside them. Once the rows are deleted, the draft address is the + * one package's draft (`packagelessDraftAddress`), so each is discarded + * by naming its package before the save writes the package-less draft. + */ + strandedDrafts?: readonly string[]; /** * Where the refusal is raised: the save door (default), a draft promotion * (the per-item publish and `publish-drafts`), or a registration of a @@ -19725,6 +19733,7 @@ export class ObjectStackProtocolImplementation implements * | a DRAFT save naming `Q`, no package's row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | * | rows bound to packages (`Q` or any other) are already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete each of them by naming its package, then the package-less definition, then a publish-mode save naming `Q`. Each row is named because a delete naming no package resolves to the one binding present, or is refused `409` over two ({@link packagelessAddress}), never to the package-less definition | * | one such row, a publish save naming NO package | it is addressed to that row ({@link packagelessAddress}); delete the row, then the same save edits the package-less definition. Over two bindings the save is refused `409` before this refusal | + * | [#23712] a DRAFT save naming NO package, whose draft address ({@link packagelessDraftAddress}) is a package `Q`'s: one row bound to `Q`, or no such row and a lone draft bound to `Q` | delete the row by naming its package, and with no package-unbound draft stored, discard each package's draft by naming its package (`?state=draft&package=`), then the same save writes the package-less draft. Deleting the row alone leaves `Q`'s draft, which the address then still names | * | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it | * | a publish naming no package of a draft bound to NO package, which the address adopts into the one stored binding `P` | no publish moves that draft into `P` (measured: with `P`'s row gone it promotes into the package-less definition; one naming `P` answers `NO_DRAFT`). Delete `P`'s row, publish again (it updates the package-less definition), then the package-less DELETE and a publish-mode save naming `P` move the object | * @@ -19769,6 +19778,23 @@ export class ObjectStackProtocolImplementation implements + `addressed into '${q}', whose row is never served. DELETE ${path}?package=${q}, then publish again: ` + `the draft lands in the package-less definition. To move it into '${q}' then: the same DELETE with no ` + `package and no dropStorage (records stay), then save it naming '${q}' in publish mode.` + tail; + } else if (state.mode === 'draft' && state.namedNoPackage && state.at !== 'promote') { + // [#23712] A draft save naming no package writes the package's + // draft the address names. Each step names its package: a delete + // or discard naming none resolves by the rows the earlier steps + // leave. With the rows and the package drafts gone, the address is + // the package-unbound draft (`packagelessDraftAddress`). + // Every step is a request, in order, spelled as the row deletes + // above are, so one row and two drafts fit the REST message bound. + const drafts = [...new Set(state.strandedDrafts ?? [])].sort(); + const steps = [ + ...rows.map((pkg) => `?package=${pkg}`), + ...drafts.map((pkg, i) => `?state=draft&package=${pkg}${i === 0 ? ' (the draft discard)' : ''}`), + ]; + message = `A draft save naming no package of object '${name}' writes a draft bound to '${q}', which no ` + + 'publish can serve: the object is served by its package-less definition. First ' + + `DELETE ${path}${steps.join(', then ')}, then send this save again: it writes the package-less ` + + 'draft.' + tail; } else if (rows.length > 0 && state.namedNoPackage) { // A save naming no package is addressed to the one binding present. message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` @@ -22579,8 +22605,28 @@ export class ObjectStackProtocolImplementation implements // owns (the `'sys_metadata'` sentinel): a save naming a package beside // it is refused here, with the move-into-a-package prescription // ({@link unboundOwnerPackageRefusal}). + // + // [#23712] …asked of the package the write lands in. A draft save + // naming no package keeps `packageId` empty (the repository resolves + // its binding, #11087), but it writes the draft + // {@link packagelessDraftAddress} names, which over a row bound to + // `Q`, or a lone draft bound to `Q`, is `Q`'s: judged with the + // request's empty package, the sentinel owner read `own` and the save + // answered `200` for a draft no publish can serve. An ambiguous + // address is left to the repository's `409`, as before. if (singularType === 'object') { - const layer = this.classifyObjectContribution(request.name, request.packageId); + const draftAddress = mode === 'draft' + && request.packageId == null + && request.source === undefined + && request.writeFace !== 'package-duplicate' + ? await this.packagelessDraftAddress( + singularType, request.name, itemDiscriminator(singularType, request.item), + ) + : undefined; + const writtenPackage = draftAddress && draftAddress.kind !== 'ambiguous' + ? draftAddress.into + : request.packageId; + const layer = this.classifyObjectContribution(request.name, writtenPackage); if (layer.kind === 'mismatch') { // [#23514] For a sentinel owner the way out depends on the // state: the mode, whether the caller named a package, and @@ -22589,9 +22635,15 @@ export class ObjectStackProtocolImplementation implements const storedPackages = layer.ownerPackageId === 'sys_metadata' ? (await this.packagelessAddress(singularType, request.name)).candidates : []; + // [#23712] With no package-unbound draft stored, the address + // is a package's draft once the rows are gone (`409` over + // two), so each such draft is discarded by its package too. + const strandedDrafts = draftAddress && draftAddress.kind !== 'ambiguous' && !draftAddress.drafts.unbound + ? draftAddress.drafts.candidates + : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, - { mode, storedPackages, namedNoPackage }, + { mode, storedPackages, namedNoPackage, strandedDrafts }, ); } } diff --git a/packages/objectql/src/protocol-sentinel-owner-draft-save-address.test.ts b/packages/objectql/src/protocol-sentinel-owner-draft-save-address.test.ts new file mode 100644 index 0000000000..aca6547dc5 --- /dev/null +++ b/packages/objectql/src/protocol-sentinel-owner-draft-save-address.test.ts @@ -0,0 +1,414 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23712, ADR-0029 D9.9 — a DRAFT save naming no package of an object its + * package-less definition owns (the `'sys_metadata'` sentinel) is judged at the + * draft address its write lands in, and refused before writing when that + * address is a package's draft, with a way out that reaches the package-less + * draft. + * + * ## The defect + * + * The save door asked `classifyObjectContribution` with the request's package, + * which a draft save naming no package leaves empty (the repository resolves + * its binding, #11087), so the sentinel owner read `own`. The write then landed + * at the draft address (`packagelessDraftAddress`, `resolveWriteHead`'s rule): + * over active rows `[package-less, Q]` (the rows #23514's defect stored) that + * is Q's draft, a new one, the stored one, or the package-unbound draft adopted + * into Q; over `[package-less]` with only Q's draft stored it is Q's draft too. + * The save answered `200` for a draft no publish can serve: the promote door + * refuses it (#23514). + * + * ## The answer, and its way out + * + * Refused `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written, as the + * publish-mode save naming no package is over the same rows. The text names, + * in order, Q's row delete (by naming Q) and, with no package-unbound draft + * stored, each package draft's discard (by naming its package): deleting the + * row alone leaves Q's draft, which the address then still names. Each pin + * parses those steps from the text itself, sends them, and boots: the same + * draft save lands in the package-less draft, and the table, its records and + * the served definition are unchanged. + * + * ## The pins + * + * - the four draft states over `[package-less, Q]` (none, package-unbound, + * Q's, both), the lone Q draft over `[package-less]`, and a row with two + * packages' drafts over `[package-less, Q]`; + * - CONTROL: over the package-less owner with no package row and no package + * draft, the draft save writes the package-unbound draft, unchanged; + * - CONTROL: an object a code package owns answers what it answered before. + * + * The publish-mode saves and the promote doors over the same rows are pinned + * by `protocol-sentinel-owner-package-save-refusal.test.ts`. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, type EngineReadOptions } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** + * In-memory driver, copied from `protocol-sentinel-owner-package-save-refusal.test.ts`; + * equality-only WHERE. `dropTable` records the tables a run drops, so a pin can + * say none was. + */ +function makeMemoryDriver(stores: Stores, drops: string[]) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + async dropTable(object: string) { drops.push(object); stores.delete(object); }, + }; + return driver; +} + +const P = 'com.qa.p'; +const Q = 'com.qa.q'; +const NAME = 'qa_probe_gadget'; +const SENTINEL = 'sys_metadata'; +const PATH = `/api/v1/meta/object/${NAME}`; +/** The data plane's reads and writes in these pins run as the system. */ +const SYSTEM: EngineReadOptions = { context: { isSystem: true } }; + +/** A runtime object body; `label` tells the writes apart. */ +const gadgetBody = (label: string) => ({ + name: NAME, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + }, +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; +} + +/** One request a refusal text names, as the protocol call the REST door makes for it. */ +type Step = { op: 'delete'; packageId?: string; state?: string } | { op: 'resend' }; + +/** + * The steps a refusal text names, in its order: `DELETE PATH?QUERY`, each + * `then ?QUERY` after it (the same DELETE with that query), and + * `send this save again`. A text that names a request this does not know + * fails the pin, never skips it. + */ +function stepsOf(message: string): Step[] { + const steps: Step[] = []; + const re = new RegExp( + `DELETE ${PATH.replace(/\//g, '\\/')}\\?(\\S+?)(?=,| \\(|$)|then \\?(\\S+?)(?=,| \\(|$)|(send this save again)`, 'g', + ); + for (const m of message.matchAll(re)) { + if (m[3]) { steps.push({ op: 'resend' }); continue; } + const query = new URLSearchParams(m[1] ?? m[2]); + const known = [...query.keys()].every((key) => key === 'package' || key === 'state'); + expect(known, `a step names an unknown parameter: ${m[0]}`).toBe(true); + steps.push({ + op: 'delete', + ...(query.get('package') ? { packageId: query.get('package')! } : {}), + ...(query.get('state') ? { state: query.get('state')! } : {}), + }); + } + return steps; +} + +describe('#23712 — a draft save naming no package of an object its package-less definition owns', () => { + const live: ObjectKernel[] = []; + afterEach(async () => { + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + }); + + /** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */ + async function boot(stores: Stores, drops: string[]): Promise { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores, drops), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + return { kernel, engine, protocol: kernel.getService('protocol') }; + } + + /** The object's stored rows as `state:binding:label`, sorted. */ + const storedRows = (stores: Stores, table: 'sys_metadata' | 'sys_metadata_history' = 'sys_metadata') => + Array.from(stores.get(table)?.values() ?? []) + .filter((r) => r.type === 'object' && r.name === NAME) + .map((r) => { + const body = typeof r.metadata === 'string' ? JSON.parse(r.metadata as string) : r.metadata as any; + return `${r.state ?? ''}:${r.package_id ?? '-'}:${body?.label ?? ''}`; + }) + .sort(); + + const draftSave = (k: Booted, packageId?: string) => k.protocol.saveMetaItem({ + type: 'object', name: NAME, mode: 'draft', ...(packageId ? { packageId } : {}), item: gadgetBody('Draft sent'), + }); + + /** The registry's owner, the data plane's definition, the metadata read's served row, and the records. */ + async function served(k: Booted) { + const read = await k.protocol.getMetaItem({ type: 'object', name: NAME }); + const records = await k.engine.find(NAME, {}, SYSTEM); + return { + owner: k.engine.registry.getObjectOwner(NAME)?.packageId, + dataPlaneLabel: (k.engine.registry.getObject(NAME) as { label?: string } | undefined)?.label, + metaReadLabel: (read?.item ?? read)?.label, + records: records.length, + }; + } + + /** + * The object saved naming no package, with two records in its table; then + * each package's active row and each draft stored beside it as earlier + * doors stored them; then a boot over those rows. + */ + async function seed( + stores: Stores, drops: string[], rows: readonly string[], drafts: ReadonlyArray, + ): Promise { + const k0 = await boot(stores, drops); + expect((await k0.protocol.saveMetaItem({ type: 'object', name: NAME, item: gadgetBody('Gadget unbound') })).success) + .toBe(true); + await k0.engine.insert(NAME, { name: 'rec-1' }, SYSTEM); + await k0.engine.insert(NAME, { name: 'rec-2' }, SYSTEM); + await k0.kernel.shutdown(); + const table = stores.get('sys_metadata')!; + const unbound = Array.from(table.values()).find((r) => r.type === 'object' && r.name === NAME)!; + const store = (id: string, pkg: string | null, state: string, label: string) => { + const body = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + body.label = label; + table.set(id, { + ...unbound, id, package_id: pkg, state, + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body, + }); + }; + for (const pkg of rows) store(`legacy_row_${pkg}`, pkg, 'active', `Gadget ${pkg} legacy`); + for (const pkg of drafts) store(`draft_${pkg ?? 'unbound'}`, pkg, 'draft', `Draft ${pkg ?? 'unbound'}`); + // The boot names the way out for each package row in a warn; these pins read the save's. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const k = await boot(stores, drops); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + return k; + } finally { + warn.mockRestore(); + } + } + + const CELLS: ReadonlyArray<{ + title: string; + rows: string[]; + drafts: Array; + /** The package the write was addressed to. */ + into: string; + /** The steps the text names, in order, before `send this save again`. */ + steps: Step[]; + /** Package drafts the walk leaves stored, beside the package-less draft it writes. */ + left: string[]; + }> = [ + { + title: '`[package-less, Q]`, no draft (the save wrote a new Q draft)', + rows: [Q], drafts: [], into: Q, left: [], + steps: [{ op: 'delete', packageId: Q }], + }, + { + title: '`[package-less, Q]`, the package-unbound draft (the save adopted it into Q)', + rows: [Q], drafts: [null], into: Q, left: [], + steps: [{ op: 'delete', packageId: Q }], + }, + { + title: '`[package-less, Q]`, Q\'s draft (deleting Q\'s row alone leaves it addressed)', + rows: [Q], drafts: [Q], into: Q, left: [], + steps: [{ op: 'delete', packageId: Q }, { op: 'delete', packageId: Q, state: 'draft' }], + }, + { + title: '`[package-less, Q]`, the package-unbound draft and Q\'s (the address keeps the unbound one once Q\'s row is gone)', + rows: [Q], drafts: [null, Q], into: Q, left: [`draft:${Q}:Draft ${Q}`], + steps: [{ op: 'delete', packageId: Q }], + }, + { + title: '`[package-less]`, Q\'s draft alone (no package row: the address is the one package\'s draft)', + rows: [], drafts: [Q], into: Q, left: [], + steps: [{ op: 'delete', packageId: Q, state: 'draft' }], + }, + { + title: '`[package-less, Q]`, P\'s and Q\'s drafts (each package draft is discarded by its package)', + rows: [Q], drafts: [P, Q], into: Q, left: [], + steps: [ + { op: 'delete', packageId: Q }, + { op: 'delete', packageId: P, state: 'draft' }, + { op: 'delete', packageId: Q, state: 'draft' }, + ], + }, + ]; + + for (const cell of CELLS) { + it(`${cell.title}: refused before writing, and the steps its text names land the package-less draft, across a boot`, async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seed(stores, drops, cell.rows, cell.drafts); + const rowsBefore = storedRows(stores); + const historyBefore = storedRows(stores, 'sys_metadata_history'); + + const err = await draftSave(k).then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(cell.into); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^A draft save naming no package of object '${NAME}' writes a draft bound to ` + + `'${cell.into.replace(/\./g, '\\.')}', which no publish can serve`, + )); + expect(err.message).toMatch(/ Nothing was written\.$/); + // The REST door bounds a 4xx message at 500 characters, tail truncated. + expect(err.message.length).toBeLessThanOrEqual(500); + // Nothing written: no row, no history entry. + expect(storedRows(stores)).toEqual(rowsBefore); + expect(storedRows(stores, 'sys_metadata_history')).toEqual(historyBefore); + + // The way out, parsed from the text and sent in its order. + const steps = stepsOf(err.message); + expect(steps).toEqual([...cell.steps, { op: 'resend' }]); + for (const step of steps) { + const res = step.op === 'resend' + ? await draftSave(k) + : await k.protocol.deleteMetaItem({ + type: 'object', name: NAME, + ...(step.packageId ? { packageId: step.packageId } : {}), + ...(step.state ? { state: step.state } : {}), + }); + expect(res.success, JSON.stringify(step)).toBe(true); + } + + const landed = [`active:-:Gadget unbound`, `draft:-:Draft sent`, ...cell.left].sort(); + expect(storedRows(stores)).toEqual(landed); + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(storedRows(stores)).toEqual(landed); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + // The draft save goes on as before after the boot, into the package-less draft. + expect((await draftSave(k2)).success).toBe(true); + expect(storedRows(stores)).toEqual(landed); + expect(drops).toEqual([]); + }); + } + + it('CONTROL: over the package-less owner with no package row and no package draft, the draft save writes the package-unbound draft', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seed(stores, drops, [], []); + + expect((await draftSave(k)).success).toBe(true); + expect(storedRows(stores)).toEqual(['active:-:Gadget unbound', 'draft:-:Draft sent']); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(storedRows(stores)).toEqual(['active:-:Gadget unbound', 'draft:-:Draft sent']); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + }); + + it('CONTROL: an object a code package owns answers the draft save naming no package as before', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + // The package's object as `registerApp` registers it: contributors only, + // with the package provenance a loader stamps. + k.engine.registry.registerObject( + { ...gadgetBody('Gadget code'), _packageId: P, _provenance: 'package' } as any, + P, + ); + expect(k.engine.registry.getPackagedObjectOwner(NAME)?.packageId).toBe(P); + + const err = await draftSave(k).then(() => null, (e: any) => e); + // [ADR-0131 D6] Managed content is sealed, so the package door answers + // ahead of the D9.9 check (measured with the new question removed too). + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(storedRows(stores)).toEqual([]); + expect(k.engine.registry.getPackagedObjectOwner(NAME)?.packageId).toBe(P); + }); +});