Skip to content

Commit 4feb2d0

Browse files
fix(metadata-protocol)!: one sentence for every write over a sealed, package-held name; the inferred create remedy retires (#23626)
Fixes #23577 Clause-②: no Executes ruling `6184789291` (batch #348 item 1, letter B) on this card. ## What changed - **One sentence for every write over a sealed, package-held name** (`packages/metadata-protocol/src/packaged-base-regime.ts`, `managedItemSealedSentence`). For a type with no regime row, every write reads: "'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." This holds for every mode and pin: the console's generic create (`mode: 'draft'` + `If-None-Match: *`), a publish-mode `*`, and unpinned and pinned saves. It gives no create advice and no source-artifact advice. The repository's type door (draft promotion, restore, revert) builds the same sentence. Code `NOT_OVERRIDABLE` and status `403` are unchanged. - **Holding package not named.** Neither door has the artifact's package at hand. `refusePackagedBaseOverride` asks `isSealedManagedItem`, a boolean, and `isArtifactBacked` discards the `lookupArtifactItem` result. `SysMetadataRepository.assertAllowed` is told only the write intent. Per the ruling, no lookup is added. - **Retired (no producer remains):** `sealedWrite` in `saveMetaItem`; the `'create'` member of `packagedBaseRefusal`'s `operation`; the `create` arm of `SealedItemOperation`; `heldNameSentence`; the `operation` parameter of `refusePackagedBaseOverride` and of `tenantAuthoredWriteRefusal` (it only ever carried `create`). `runtime/src/domains/automation.ts` passes `'save' | 'delete'` and is untouched. - **Position 2:** the `## create` section of `packagedBaseRefusal`'s docblock, which named the retired position write-through, is replaced by a short "one `save`, whatever the write meant" note. - **Spec docblocks (ruling item 2):** `GetMetaItemResponseSchema.version` and `MetadataConflictErrorSchema.currentVersion` (`packages/spec/src/api/protocol.zod.ts`) now say that a `null` beside `editable: false` takes no save and prescribe no pin for it. These are `.describe()` texts. The only generated output that moved is `content/docs/references/api/protocol.mdx`, written by `check:generated --fix` (`gen:docs`). - **Position 3:** the clone door's lost-race 409 came from the protocol's first-write-pin conflict sentence (`metadataConflictRefusal`, "Send that version as If-Match …"), which the clone door relayed through `errorFromThrown`. The fix is in the clone door, the producer of the clone caller's response. A `METADATA_CONFLICT` from its create-only save is rethrown as the door's own name-taken refusal: 409, default code `RESOURCE_CONFLICT`, the same sentence a name the catalog already held reads. The `/meta` door's conflict sentence is unchanged, so it stays correct for REST callers that send `If-Match`. - `scripts/engine-double-contract.pinned.json`: the three rows `check:engine-double-contract --write` adds for the new `view` control harness. - Changeset `.changeset/23577-sealed-write-one-sentence.md` (minor: metadata-protocol, with the **BREAKING** line and its ADR-0087 disposition; patch: runtime, spec). - `.changeset/23406-sealed-first-draft-remedy.md` (unconsumed, the same release) is amended in place so it no longer states the draft/publish remedy split this PR retires; it keeps its first-write-pin `409` sentence. A falsified entry is corrected in the entry itself, never by an erratum in a later one (AGENTS.md, the `packages/*/CHANGELOG.md` row), so this PR's changeset carries no "supersedes" line (`f0761ed3`, the seat's patch round). ## Pins (ruling item 4) `protocol.sealed-remedy-kind.test.ts` is re-pointed. #23565's draft/publish block now asserts the one sentence. On `position`, `object` and `flow`, on both kernel shapes, these five requests all read the identical refusal: console create (draft + `*`), publish `*`, unpinned draft, unpinned, pinned. For `position` and `object`, that refusal is the ruled sentence. The registry enumeration holds every row-less reachable type to the ruled sentence, under the 500-character bound with an 88-character name. An overlay control shows that a `view` first write pinned `*` over a packaged view lands an overlay row (`success: true`, one row). The clone door has a new lost-race pin: 409 `RESOURCE_CONFLICT`, the name-taken sentence, and no `If-Match`. #23565's REST-door pin `rest-meta-sealed-first-draft-remedy.test.ts` is re-pointed to the one sentence for the same requests on both kernels (`3f354c73`). ## Verification - Closure build `pnpm --filter '@objectstack/runtime^...' --filter @objectstack/runtime build`: exit 0. - `pnpm --filter … run typecheck` for spec, metadata-protocol, runtime: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: 236 files passed, 3 skipped; 28418 tests passed. - `pnpm --filter @objectstack/runtime test`: 383 files, 5773 tests passed. - `pnpm --filter @objectstack/spec test`: 678 files, 20728 tests passed. - Touched pins in other packages: `rest-meta-packaged-flow-refusal.test.ts` and `rest-meta-sealed-first-draft-remedy.test.ts` passed 6/6; `objectql protocol-commit-history.test.ts` passed 30/30. CI Test Core 5/6 caught `rest-meta-sealed-first-draft-remedy.test.ts` red at `9ec5053e` (it still pinned the retired split); it was re-pointed in `3f354c73`, and the full `rest` run at `9ec5053e` had its 284 other files green. - `f0761ed3` (changesets only): `check-empty-changeset`, `check-changeset-no-major`, `check-adr-0087-registration`, `pnpm changeset status --since=origin/main`, `check-closing-keyword-parity` and `check-changeset-fixed` exit 0. - `pnpm --filter @objectstack/spec check:generated`: one stale artifact (`check:docs`); after `--fix`, re-check green. - Ablations, committed first, each restored with blob equal to HEAD and an empty `git diff HEAD` (`scripts/ablation-replace.mjs`): - Clone guard (`METADATA_CONFLICT` to a renamed code): 1 red / 22 green, predicted 1 red. - Sentence builder (the `save` branch disabled): 7 red / 6 green, predicted. The row-less pins went red; flow, reach, removal and the `view` control stayed green. - `dispatch-gates --commands` local union (114 derived plus 5 roster families under my paths), first run at `51f2e48b`: - Real reds, both fixed in `9ec5053e`: `check:durability-log-level` (the clone's new catch; it now rethrows to the door's one `errorFromThrown`) and `check:engine-double-contract` (ledger rows). Both re-run green. - Exit 2/3 prerequisites, NOT MEASURED: `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test` (shallow clone); `check:skill-examples` and `check:dual-build-cjs-loads` (whole-workspace build absent). - The final-head re-run of the union is recorded in the card's report comment. ## Acceptance notes - **Scope of "every write": Regime C and origin-gated rows are kept.** `flow`, `action` and `permission` keep their regime sentence. ADR-0126 §2 requires a Regime C refusal to name its sanctioned path, and those sentences carry neither create advice nor source advice. `datasource` is origin-gated: its items are code-defined, not package-held, and its sentence states the datasource-admin door's own verdict. The ruling's fact base is the row-less pair `managedItemSealedSentence` built. Raised in the report's `open_questions`. - **Removals unchanged.** Every removal keeps its sentence, the hatch-naming one included. - **`ITEM_LOCKED` named-base limb unchanged.** When a caller names a read-only base (`readOnlyBaseOverrideError`), the limb keeps its own code and its "Edit the source artifact and redeploy" remedy. The ruling binds the package-less `NOT_OVERRIDABLE` refusal. - **Unreleased changeset.** `.changeset/23406-sealed-first-draft-remedy.md` described the split this PR retires. It is amended in place (see the What changed list), and the `Check Changeset` step-12 red that this produces is the gate's deliberate-correction class, carried to the maintainer by `needs-user-decision` (comment 6186537521). - **Breaking declaration.** The `'create'` member of `packagedBaseRefusal`'s `operation` and `tenantAuthoredWriteRefusal`'s `operation` parameter are a released public input surface (`18.0.0-next.0`), so the `@objectstack/metadata-protocol` changeset carries a `**BREAKING**` line, an ADR-0087 disposition and the launch-window `minor` grade (contract review 6186902880, ②). - The 409 lost-race answer of the clone door now reads code `RESOURCE_CONFLICT` instead of `METADATA_CONFLICT`. That is the code the door already declares for a taken name. --- _Generated by [Claude Code](https://claude.ai/code/session_01M83DE8c8ySe7XfBTNTxzFd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d1fb1f commit 4feb2d0

15 files changed

Lines changed: 339 additions & 345 deletions

‎.changeset/23406-sealed-first-draft-remedy.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
'@objectstack/metadata-protocol': patch
33
---
44

5-
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
5+
fix(metadata-protocol): a refused first-write pin is told what it asserted
66

77
Clause-②: no
88

9-
**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.
9+
**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.
1010

1111
**What changed.**
1212

13-
- 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.
14-
- 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.
13+
- 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.
1514

1615
**Unchanged.** Which requests are accepted or refused, every code and status, and the conflict body's `currentVersion` and the refusal's keyed fields.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
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
8+
9+
Clause-②: no
10+
11+
<!-- 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. -->
12+
13+
**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.
14+
15+
**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.
16+
17+
**What changed.**
18+
19+
- `@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.
20+
- `@objectstack/metadata-protocol` (retired, no producer remains): the two signature members named under **BREAKING** above, and the `create` arm of the internal `SealedItemOperation`.
21+
- `@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.
22+
- `@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`.
23+
24+
**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.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,7 +1453,7 @@ This schema accepts one of the following structures:
14531453
| **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. |
14541454
| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. |
14551455
| **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. |
1456-
| **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. |
1456+
| **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. |
14571457
| **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. |
14581458

14591459
### Nested Shape: `GetMetaItemResponse.sortability`
@@ -2386,7 +2386,7 @@ Installed package with runtime lifecycle state
23862386
| :--- | :--- | :--- | :--- |
23872387
| **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. |
23882388
| **code** | `'METADATA_CONFLICT'` | ✅ | ADR-0112 registered code of the optimistic-lock refusal. |
2389-
| **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. |
2389+
| **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. |
23902390

23912391

23922392
---

0 commit comments

Comments
 (0)