Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/23657-draft-save-one-package-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/spec': patch
---

fix(metadata-protocol)!: a draft save that names no package, of an item never published into a package, writes the draft the publish and the discard naming none act on, and is refused `409 RESOURCE_CONFLICT` where they are; the draft discard's receipt names the package (ADR-0048, ADR-0070)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is added, removed, renamed or re-shaped (the spec changes one description only), and no stored row is rewritten, converted or deleted by this change itself, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime write door: a draft save through the metadata API that names no package, of an item with no active row bound to a package and no package-unbound draft, is refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts of it; the remedy is in the request a caller sends (name the package with `?package=`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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 an item can hold a draft per package. Over an item never published into a package (no active row bound to one), the per-item publish (`POST /api/v1/meta/TYPE/NAME/publish`) and the draft discard (`DELETE /api/v1/meta/TYPE/NAME?state=draft`) that name no package act on the package-unbound draft, else on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. The draft save naming no package did not take that address.

**FROM.** Over such an item, `PUT /api/v1/meta/TYPE/NAME?mode=draft` with no `?package=`:

- With the only draft bound to package `Q` (saved with `?mode=draft&package=Q`), the save wrote a package-unbound draft beside `Q`'s and answered `200` ("Saved … (env-wide, state=draft)"). The drafts became `[package-unbound, Q]`, and from then on the publish and the discard naming no package acted on the package-unbound one, while `Q`'s draft stayed pending.
- With drafts bound to two or more packages and no package-unbound draft, the save wrote a package-unbound draft beside them and answered `200`, while the publish and the discard naming no package were refused `409`.
- `DELETE …?state=draft` of a package's draft answered "Draft discarded — TYPE/NAME", without the package.

**TO.**

- With the only draft bound to `Q`, the save updates `Q`'s draft and its receipt names the package ("Saved view 'my_view' (env-wide, package=Q, state=draft)"). The strict draft read's `version` (`GET …?state=draft`) is that draft's, so a save pinned to it with `If-Match` is accepted; the publish and the discard naming no package act on the same draft.
- With drafts bound to two or more packages and no package-unbound draft, the save is refused `409 RESOURCE_CONFLICT` and writes nothing, as the publish and the discard are. The message names the packages and the remedy, `?package=`.
- A draft discard of a package's draft names the package: "Draft discarded (package=Q) — TYPE/NAME. [seq=N]". The discard of a package-unbound draft reads as before.

**What follows from the save writing `Q`'s draft**, over an item never published into a package whose only draft is bound to `Q`, for a draft save naming no package:

- FROM `If-Match` carrying `Q`'s draft token (the version `GET …?state=draft&package=Q` serves) refused `409 METADATA_CONFLICT`, TO accepted: that draft is the row the save writes, so the strict draft read naming no package now serves the same token.
- FROM `If-None-Match: *` (a first-write pin) creating a package-unbound draft and answering `200`, TO `409 METADATA_CONFLICT` with nothing written: `Q`'s draft is the row the save would overwrite.
- FROM a permission set whose body declares `packageId: Q` refused `400 VALIDATION_ERROR` (the owner binding of ADR-0086 D3, judged against a save "into no package"), TO accepted: the binding the write resolves is `Q`. A body declaring another package is still refused, and the refusal now names `Q`.

`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states the draft save's address in the words the publish's and the discard's descriptions use: the draft of the one package the item's active rows are bound to, else its package-unbound draft; for an item never published into a package, the package-unbound draft, else the one package's draft; refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. It said the save wrote "the env-local draft" whenever the active row was not bound to a package. No key, type, enum or default moves.

**Unchanged.** A draft save that names its package (`?package=`, `packageId` in process). An item with an active row bound to a package, whose draft save naming no package still inherits that package. An item with a package-unbound draft, which a draft save naming no package still updates, whatever packages' drafts sit beside it. A brand-new item with no draft at all, whose first draft save naming no package writes the package-unbound draft. No stored row is rewritten or removed by the upgrade.

**Remedy.** For an item two or more packages hold drafts of, send the draft save with `?package=` naming the package (`packageId` in process). A client that compares draft-discard receipt text should expect ` (package=ID)` after `Draft discarded` when the draft discarded is bound to a package.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2742,7 +2742,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=<id>` 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`. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=<id>` 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 one package the item's active rows are bound to (a pending change stays in the package it edits), else its package-unbound draft, adopted into that package — the draft the per-item publish naming no package promotes. For an item never published into a package it is the package-unbound draft, else the one package's draft, and with no draft at all the save creates the package-unbound draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages, as that publish is (see `PublishMetaItemRequestSchema.packageId`). 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. |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,23 @@ describe('[ADR-0086 D3] the save door refuses a permission set whose body `packa
]);
});

it('[#23657] an item never published into a package, whose only draft is BOUND\'s: a draft naming no package is judged against BOUND, the draft it writes', async () => {
// No active row, so the draft address is read from the drafts: the
// only draft is BOUND's, and a package-less draft save updates it.
// Before, the save wrote a package-unbound draft beside it, and an
// agreeing body was refused 400 for declaring a package "into no package".
const h = makeProtocol();
await h.protocol.saveMetaItem({ type: 'permission', name: 'probe_member', item: perm('probe_member', BOUND), packageId: BOUND, mode: 'draft' });

const { error } = await attempt(() => h.protocol.saveMetaItem({
type: 'permission', name: 'probe_member', item: perm('probe_member', OTHER), mode: 'draft',
}));
expectOwnerRefusal(error, OTHER, BOUND);

await h.protocol.saveMetaItem({ type: 'permission', name: 'probe_member', item: { ...perm('probe_member', BOUND), label: 'Edited' }, mode: 'draft' });
expect(storedSets(h)).toEqual([{ row: 'probe_member', state: 'draft', binding: BOUND, body: BOUND }]);
});

it('a direct save that names no package is judged against the one package its address resolves to', async () => {
// The item's only active row is bound to BOUND, so a package-less
// direct save writes BOUND's row: an agreeing body passes, and a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,9 @@ describe('[#23495] the draft doors naming no package act on the draft the draft
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pending' }), packageId: P, mode: 'draft' });
} else {
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'pending' }), packageId: P, mode: 'draft' });
await saveDraft(f, 'stale');
// [#23657] Seeded, as an older build left it: a draft save naming no
// package over an item whose only draft is P's now updates P's draft.
f.seedRow({ type: 'app', name, state: 'draft', package_id: null, metadata: JSON.stringify(app(name, { label: 'stale' })), checksum: 'sha-stale' });
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live' }), packageId: P });
}
return f;
Expand Down Expand Up @@ -1180,7 +1182,9 @@ describe('[#23495] the draft doors naming no package act on the draft the draft
const f = { ...makeProtocol(), name };
if (unboundFirst) await saveDraft(f, 'unbound');
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'other' }), packageId: Q, mode: 'draft' });
if (!unboundFirst) await saveDraft(f, 'unbound');
// [#23657] Seeded, as an older build left it: a draft save naming
// no package over Q's only draft now updates Q's draft.
if (!unboundFirst) f.seedRow({ type: 'app', name, state: 'draft', package_id: null, metadata: JSON.stringify(app(name, { label: 'unbound' })), checksum: 'sha-unbound' });
return f;
};

Expand Down
28 changes: 18 additions & 10 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
packageScopedRowWhere,
packagelessAddressAmbiguityRefusal,
resetEnvWritableMetadataTypes,
unboundItemDraftAddress,
type SysMetadataEngine,
} from './sys-metadata-repository.js';
import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
Expand Down Expand Up @@ -19135,10 +19136,12 @@ export class ObjectStackProtocolImplementation implements
* with the refusal the draft save answers
* ({@link packagelessAddressAmbiguityRefusal}, `409 RESOURCE_CONFLICT`).
* - bound to none (an item never published into a package): the
* package-unbound draft, the one the draft save writes. With no such
* draft, the one package's draft when exactly one package holds a draft
* (the only draft the read naming no package can serve); two or more
* is `ambiguous`; none at all is `none`.
* package-unbound draft. With no such draft, the one package's draft
* when exactly one package holds a draft (the only draft the read
* naming no package can serve); two or more is `ambiguous`; none at
* all is `none`. [#23657] The draft save naming no package writes the
* same draft and is refused the same way: one rule,
* {@link unboundItemDraftAddress}, which `resolveWriteHead` asks too.
*
* Every row is read in full, never sampled: a package-agnostic `findOne`
* over two drafts is a driver-order coin toss (#8907). The per-item publish
Expand Down Expand Up @@ -19176,11 +19179,11 @@ export class ObjectStackProtocolImplementation implements
if (drafts.unbound) return { kind: 'draft', key: null, into: own, drafts };
return { kind: 'none', key: own, into: own, drafts };
}
if (drafts.unbound) return { kind: 'draft', key: null, into: null, drafts };
if (drafts.candidates.length > 1) return { kind: 'ambiguous', candidates: drafts.candidates };
const only = drafts.candidates[0];
if (only !== undefined) return { kind: 'draft', key: only, into: only, drafts };
return { kind: 'none', key: null, into: null, drafts };
// [#23657] Bound to none: the rule the draft save's address takes too
// ({@link unboundItemDraftAddress}, in `resolveWriteHead`).
const address = unboundItemDraftAddress(drafts);
if (address.kind === 'ambiguous') return address;
return { kind: address.kind, key: address.key, into: address.key, drafts };
}

/**
Expand Down Expand Up @@ -29794,8 +29797,13 @@ export class ObjectStackProtocolImplementation implements
// 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.
//
// [#23657] The draft discard names the package of the
// draft it removed too, as the draft save's receipt names
// the package it wrote into; a package-unbound draft's
// discard reads byte for byte as before.
message: (request.state === 'draft')
? `Draft discarded — ${request.type}/${request.name}. [seq=${result.seq}]`
? `Draft discarded${deletedPackage} — ${request.type}/${request.name}. [seq=${result.seq}]`
: artifactBacked
? (remains
? `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} still carries another overlay: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]`
Expand Down
Loading
Loading