Skip to content

Commit 740f97b

Browse files
committed
fix(spec): the publish and delete request contracts state what a draft door naming no package acts on; changeset
Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13d3124 commit 740f97b

3 files changed

Lines changed: 60 additions & 14 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no authorable spelling, export or stored shape is removed, renamed or re-shaped, 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 two runtime write doors: a per-item publish or a draft discard through the metadata API that names no package is refused `409 RESOURCE_CONFLICT` for an item whose active rows two or more packages hold, and a publish naming no package answers `404 NO_DRAFT` where the only draft is bound to a package other than the item's own; 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 packages publish (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). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a draft per package. A draft save that names no package (`PUT /api/v1/meta/TYPE/NAME?mode=draft`) writes the draft of the package the item's active rows are bound to, and the strict draft read and the draft preview that name no package serve that draft. The other two draft doors did not take that address.
15+
16+
**FROM.** Over an item whose active rows are bound to package `P`:
17+
18+
- With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged.
19+
- With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft.
20+
- With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves.
21+
22+
Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package.
23+
24+
**TO.** A per-item publish and a draft discard that name no package act on the draft the draft save naming no package writes:
25+
26+
- **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain.
27+
- **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`.
28+
- **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft; when two or more packages hold drafts, both are refused `409 RESOURCE_CONFLICT`.
29+
30+
The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before.
31+
32+
**Unchanged.** A publish or a discard that names its package (`?package=`, `packageId` in process). A publish stating `packageId: null` in process, which still pins the package-unbound draft. The batch publish and discard of a package's drafts (`POST /packages/:id/publish-drafts`, the discard of a package's drafts). No stored row is rewritten or removed by the upgrade.
33+
34+
**Remedy.** For an item two or more packages hold, or to publish or discard a draft bound to a package other than the item's own, send the request with `?package=` naming that package (`packageId` in process). A client that compares draft-save receipt text should expect `, package=<id>` after `env-wide` when the draft inherits a package.
35+
36+
`@objectstack/spec`: the `PublishMetaItemRequestSchema.packageId` description now states what a publish naming no package promotes, where it said "match any package"; the `DeleteMetaItemRequestSchema.packageId` description states what a draft discard naming no package removes.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
729729
| **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`. |
730730
| **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. |
731731
| **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. |
732-
| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=<id>` 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. |
732+
| **packageId** | `string \| null` | optional | ADR-0048 — the software package whose row this delete removes (`sys_metadata.package_id`; `?package=<id>` on the REST door, where `all` and an empty value name no package), as a save names the row it writes. Named, the delete removes that package's row and no other; a package that holds no row removes nothing. Absent or `null` names no package: the delete of the `active` row then removes the row of the one package the item's active rows are bound to, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none removes the env-local row (no package stamp); the discard of the `draft` removes the draft a draft save naming no package writes, the one the per-item publish naming none promotes, and is refused where that publish is (see `PublishMetaItemRequestSchema.packageId`). That row is the one `parentVersion` is judged against. When another row of the item is still stored afterwards, the receipt `message` names it. |
733733

734734

735735
---
@@ -2442,7 +2442,7 @@ Installed package with runtime lifecycle state
24422442
| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. |
24432443
| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. |
24442444
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
2445-
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. |
2445+
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. |
24462446
| **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. |
24472447

24482448

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,14 +1038,20 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({
10381038
packageId: z.string().nullable().optional().describe(
10391039
'ADR-0048 — the software package the draft being promoted was listed '
10401040
+ 'under, when the caller has one to state (`?package=<id>` on the REST '
1041-
+ 'door). ⚠️ `null` is NOT the same as absent, and the '
1042-
+ 'difference is load-bearing: the implementation branches on the KEY '
1043-
+ 'BEING PRESENT, so an ABSENT key keeps the historical "match any '
1044-
+ 'package" resolution while `null` pins the lookup to the '
1045-
+ 'package-UNBOUND row. Spread it in conditionally; a '
1046-
+ 'present-and-`undefined` key coerces to `null` downstream and makes a '
1047-
+ 'package-bound draft unfindable — a silent `no_draft` on the untouched '
1048-
+ 'path.',
1041+
+ 'door). Named, the publish promotes that package\'s draft and no other. '
1042+
+ 'ABSENT names no package: the publish promotes the draft a draft save '
1043+
+ 'that names no package writes — the draft of the one package the '
1044+
+ 'item\'s active rows are bound to, else its package-unbound draft, '
1045+
+ 'promoted into that package — and answers `404 NO_DRAFT` when neither '
1046+
+ 'exists, as the strict draft read does. For an item never published into '
1047+
+ 'a package it is the package-unbound draft, else the one package\'s '
1048+
+ 'draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, '
1049+
+ 'with none, the drafts) are bound to two or more packages. ⚠️ `null` is '
1050+
+ 'NOT the same as absent: the implementation branches on the KEY BEING '
1051+
+ 'PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread '
1052+
+ 'it in conditionally; a present-and-`undefined` key coerces to `null` '
1053+
+ 'downstream and makes a package-bound draft unfindable — a silent '
1054+
+ '`no_draft`.',
10491055
),
10501056
member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION),
10511057
}));
@@ -1517,11 +1523,15 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({
15171523
+ '`all` and an empty value name no package), as a save names the row it '
15181524
+ 'writes. Named, the delete removes that package\'s row and no other; a '
15191525
+ 'package that holds no row removes nothing. Absent or `null` names no '
1520-
+ 'package: the delete then removes the row of the one package the '
1521-
+ 'item\'s rows at the deleted `state` are bound to, is refused `409 '
1526+
+ 'package: the delete of the `active` row then removes the row of the '
1527+
+ 'one package the item\'s active rows are bound to, is refused `409 '
15221528
+ 'RESOURCE_CONFLICT` when they are bound to two or more, and with none '
1523-
+ 'removes the env-local row (no package stamp). That row is the one '
1524-
+ '`parentVersion` is judged against. When another row of the item is '
1529+
+ 'removes the env-local row (no package stamp); the discard of the '
1530+
+ '`draft` removes the draft a draft save naming no package writes, the '
1531+
+ 'one the per-item publish naming none promotes, and is refused where '
1532+
+ 'that publish is (see `PublishMetaItemRequestSchema.packageId`). That '
1533+
+ 'row is the one `parentVersion` is judged against. When another row of '
1534+
+ 'the item is '
15251535
+ 'still stored afterwards, the receipt `message` names it.',
15261536
),
15271537
}));

0 commit comments

Comments
 (0)