Skip to content
Merged
7 changes: 3 additions & 4 deletions .changeset/23406-sealed-first-draft-remedy.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): a first draft of a sealed, package-shipped item is told to edit the source artifact, not to choose another name; a refused first-write pin is told what it asserted
fix(metadata-protocol): a refused first-write pin is told what it asserted

Clause-②: no

**What was wrong.** `PUT /api/v1/meta/:type/:name?mode=draft` with `If-None-Match: *` on an item a code package ships, on a type with no environment overlay (an `object`, for one), answered `403 NOT_OVERRIDABLE` with the remedy for a create: "… so its name is taken. To author your own object, choose a name no package or built-in holds …". The same draft sent without the header was told "… Edit the source artifact and redeploy." The header asserts only that no draft is pending, so the request is a first draft of the item already under the name, an edit. A client or AI author that followed the create remedy authored a second object instead of editing the source.
**What was wrong.** A save that sent `If-None-Match: *` (the first-write pin) to `PUT /api/v1/meta/:type/:name` over a row that already exists was refused `409 METADATA_CONFLICT` with "TYPE/NAME has been modified since you loaded it. Expected parent null but current is VERSION.", though the caller had loaded nothing.

**What changed.**

- A first draft over a held name names the item's own remedy, the sentence the same draft reads without the header (for a type with a regime row, its sanctioned path). A first write of the live row (`If-None-Match: *` with no `?mode=draft`) still names a free name.
- A save refused `409 METADATA_CONFLICT` because it sent `If-None-Match: *` over an existing row now says so: "TYPE/NAME: this save asserted that no draft row exists (the first-write pin, If-None-Match: * on the REST door), but one does (current is VERSION). …" (`active row` for a save without `?mode=draft`). It used to read "TYPE/NAME has been modified since you loaded it. Expected parent null but current is VERSION.", though the caller had loaded nothing. Every other conflict keeps its sentence.
- Such a save is now told what it asserted: "TYPE/NAME: this save asserted that no draft row exists (the first-write pin, If-None-Match: * on the REST door), but one does (current is VERSION). …" (`active row` for a save without `?mode=draft`). Every other conflict keeps its sentence.

**Unchanged.** Which requests are accepted or refused, every code and status, and the conflict body's `currentVersion` and the refusal's keyed fields.
24 changes: 24 additions & 0 deletions .changeset/23577-sealed-write-one-sentence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/runtime': patch
'@objectstack/spec': patch
---

fix(metadata-protocol): every write over a sealed, package-held name reads one sentence that states what the door knows; the inferred create remedy retires

Clause-②: no

<!-- adr-0087: not-required (runtime-interface-only packages/metadata-protocol/src/protocol.ts#ObjectStackProtocolImplementation) No metadata moves: no spec key, authorable spelling, stored row or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the TypeScript signature of two public methods of the exported runtime class: `packagedBaseRefusal`'s `operation` loses its `'create'` member, and `tenantAuthoredWriteRefusal` loses its optional `operation` parameter. The channel that reaches every affected consumer is the compiler, which refuses the retired argument where it is written. -->

**BREAKING** (a public method-signature narrowing in `@objectstack/metadata-protocol`), shipped as `minor` under the launch-window convention for breaking changes. The `'create'` member of `ObjectStackProtocolImplementation.packagedBaseRefusal`'s `operation` (now `'save' | 'delete'`) and the optional `operation` parameter of `tenantAuthoredWriteRefusal` retire. A caller that passed `operation: 'create'` passes `'save'` (or omits `operation` on `tenantAuthoredWriteRefusal`): the verdict, the code and the status were always the same.

**What was wrong.** A save over a name a managed package holds, on a type with no environment overlay, is refused `403 NOT_OVERRIDABLE`. The sentence guessed what the save meant from `If-None-Match: *`. A publish-mode `*` was told "… so its name is taken. To author your own TYPE, choose a name no package or built-in holds …", and every other save, the console's generic create (`?mode=draft` with `If-None-Match: *`) included, was told "… Edit the source artifact and redeploy." Neither request declares a create or an edit, so each remedy was wrong for one of its callers. An editor who followed the first authored a second item. A creator who followed the second, and every administrator of an installed package, was sent to an artifact they cannot edit.

**What changed.**

- `@objectstack/metadata-protocol`: every write over such a name, on a type with no regime row, reads one sentence, whatever its mode or pin: "'TYPE/NAME' is provided by a managed package and is sealed: it cannot be written here. GET /api/v1/meta/TYPE lists the names in use." The same sentence comes from the repository's type door (draft promotion, restore, revert). It gives no create advice and no source-artifact advice. It names no holding package, because neither door has the package at hand. A type with a regime row (`flow`, `action`, `permission`, `datasource`) keeps its regime sentence, and every removal keeps its sentence.
- `@objectstack/metadata-protocol` (retired, no producer remains): the two signature members named under **BREAKING** above, and the `create` arm of the internal `SealedItemOperation`.
- `@objectstack/spec`: the `GetMetaItemResponseSchema.version` and `MetadataConflictErrorSchema.currentVersion` descriptions say that a `null` beside `editable: false` takes no save, and prescribe no `If-None-Match: *` pin for it.
- `@objectstack/runtime`: a permission-set clone (`POST /api/v1/security/permission-sets/:name/clone`) whose save loses the race for the target name now answers the door's own `409` name-taken refusal ("Permission set 'NAME' already exists — a clone must take a NEW machine name. …", code `RESOURCE_CONFLICT`, where it used to answer code `METADATA_CONFLICT`), the same refusal a name the catalog already held reads. It used to relay the `/meta` door's conflict sentence, which told the caller to send `If-Match`, and this door takes no `If-Match`.

**Unchanged.** Which requests are accepted or refused. The `NOT_OVERRIDABLE` code and `403` status. The named read-only base's `ITEM_LOCKED` sentence. The `/meta` door's conflict sentence for its own callers.
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1453,7 +1453,7 @@ This schema accepts one of the following structures:
| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. |
| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. |
| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. |
| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. |
| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. |
| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. |

### Nested Shape: `GetMetaItemResponse.sortability`
Expand Down Expand Up @@ -2386,7 +2386,7 @@ Installed package with runtime lifecycle state
| :--- | :--- | :--- | :--- |
| **error** | `string` | ✅ | Human-readable refusal sentence, unchanged by `currentVersion`: it names the item and the version the door judged current. For display and logs; branch on `code` and read `currentVersion`, never parse this. |
| **code** | `'METADATA_CONFLICT'` | ✅ | ADR-0112 registered code of the optimistic-lock refusal. |
| **currentVersion** | `string \| null` | ✅ | The version token of the head the door judged current — the value the sentence names — or `null` when no row of the target lifecycle exists. Send it as `If-Match` to overwrite deliberately (or `If-None-Match: *` when it is `null`), or re-read the item to merge first. Opaque — echo it verbatim, never parse it. |
| **currentVersion** | `string \| null` | ✅ | The version token of the head the door judged current — the value the sentence names — or `null` when no row of the target lifecycle exists. Send it as `If-Match` to overwrite deliberately (or `If-None-Match: *` when it is `null`), or re-read the item to merge first. A `null` for an item whose read serves `editable: false` prescribes no pin: that item takes no save. Opaque — echo it verbatim, never parse it. |


---
Expand Down
Loading
Loading