Skip to content

Commit ad14957

Browse files
fix(metadata-protocol): a sealed-item refusal of a create names a free name, not the edit remedy (#22591) (#22623)
Fixes #22591 Clause-②: yes The sealed-item refusal (`403 NOT_OVERRIDABLE`) now names the remedy that fits what the caller did. This closes out the family that #20819 (flow) and #20910 (action, permission) worked through one type at a time. - **An edit** of the item under a held name keeps its sentence, byte for byte. That is the row's sanctioned path, or "Edit the source artifact and redeploy" for a type without a row. - **A create** under a name that a managed package or a built-in holds, or **a rename into** such a name, is now told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/TYPE` lists the names in use. Which writes are refused does not change, and neither do the code or the status. ## Measured at the real doors (showcase, `single`) The probe was a local file, never committed and deleted after the run. It booted the real showcase composition through `bootShowcase`, signed in as the seeded admin, and used the HTTP doors. **Before** was `origin/main` `d748ae80af`, with the dogfood dependency closure built from it. **After** was this branch at `6499e49c0f`, with `metadata-protocol` and `plugin-security` rebuilt from that head. | Door and request | Before | After | |---|---|---| | Setup `POST /data/sys_position` with `manager` (package `com.example.showcase`) | 403 `NOT_OVERRIDABLE`, "… Edit the source artifact and redeploy." | 403 `NOT_OVERRIDABLE`, "… so its name is taken. To author your own position, choose a name no package or built-in holds (GET /api/v1/meta/position lists the names in use)." | | Setup create of `guest` (built-in, `com.objectstack.plugin-security`) | 403, edit remedy | 403, create remedy | | Setup `PATCH` renaming an admin position into `exec` | 403, edit remedy | 403, create remedy | | `PUT /meta/position/manager` with `If-None-Match: *` (the first-write precondition) | 403, edit remedy | 403, create remedy | | `PUT /meta/flow/showcase_task_completed` with `If-None-Match: *` | 403, clone and switch | 403, create remedy, citing ADR-0126 | | `PUT /meta/position/manager` with no precondition, and with `?mode=draft` | 403, edit remedy | 403, edit remedy (unchanged) | | `PUT /meta/flow/showcase_task_completed` with no precondition | 403, clone and switch | unchanged | | `POST /automation` onto `showcase_task_completed` | 403, clone and switch | unchanged (see the Acceptance notes) | | Setup create of `p22591_lead` (control) | 201 | 201 | `GET /api/v1/meta/position` listed `manager`, `exec`, `everyone`, `guest` and the other held names, so the remedy's pointer answers. ## One create-aware sentence, not a `position` row This was the claimant's choice to make by measurement. The create remedy does not depend on the type: on every type, a create under a held name has one remedy, which is another name. So `heldNameSentence` in `packaged-base-regime.ts` is one sentence for every type. Only its opener and its citation come from the type's row, as in the edit sentences. An origin-gated `datasource` is "code-defined", not shipped by a managed package. A Regime C type cites ADR-0126, whose §2 makes a new machine name mandatory for an item authored beside a packaged one. A per-type `position` row would have fixed one of the 24 reachable types and left the rest for a fourth card. A type added to the registry later gets the create remedy without a row. ## Where the act comes from The act is the caller's fact and is never inferred. A door that cannot tell keeps the edit remedy. - `managedItemSealedSentence(type, name, operation)` takes `'save' | 'create' | 'delete'`. A `'create'` reads `heldNameSentence`. - `ObjectStackProtocolImplementation.packagedBaseRefusal` takes `operation: 'create'`, with the same predicate, code and status as `'save'`. `refusePackagedBaseOverride` passes the act through to the sentence and to nothing else. - **Setup** (`plugin-security`, `position-write-through.ts`): the S10 verdict is asked only for a create or a rename into a name, so it now asks with `operation: 'create'`. This is the producer of the card's measured symptom, in another package. The dispatch allowed fixing it at the producer. - **The `/meta` save door** (`saveMetaItem`): its one fact about the name is the first-write pin. A present `null` `parentVersion` (`If-None-Match: *`) asserts that the caller holds no row there, which the spec calls the first-write pin, so it is read as a create. It reaches both locked-base doors: `tenantAuthoredWriteRefusal` (flows, which gains an optional `operation`) and the package door. Any other save is an edit. - **`SysMetadataRepository.assertAllowed`** (the third caller the dispatch named) is unchanged. Its saves are server replays of the item's own stored history (draft promotion, restore, revert), which are edits, and it has no create fact to pass. ## Tests - `packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts` (new): - **Enumeration pin.** Every type the refusal reaches is measured by asking the verdict, never from a transcribed list. That is the 23 registry types with no overlay channel plus a plugin-registered type, on both kernel shapes. Asserted equal to the registry's own flag, with a floor. For each type, an edit names the edit remedy and a create names the create remedy. The kind is read off what the sentence points at: the row's own routes or source (read from `PACKAGED_BASE_REGIME`), the source/redeploy remedy, or the type's listing route. The wording is not asserted. - **Controls.** A create is refused exactly where a save is, with `NOT_OVERRIDABLE` / 403 on every row. A free name is `null` for both acts. A removal names no new name. Every create sentence for an 88-character name arrives whole under the REST door's 500-character bound, with no hatch, clone or switch. - **The `/meta` door.** For `position` and `flow`: a first-write save names the create remedy, and an unpinned or `If-Match` save keeps the edit remedy, with the same code and status. The door's message equals the verdict's (one emitter). - `packages/plugins/plugin-security/src/position-write-through.test.ts`: - S10's "the door's own refusal" pin now compares the Setup create with the door's first-write save, because a create relays the door's answer to a create. - New: a create under a package-held name and under a built-in (`everyone`) names the create remedy, while the door's edit refusal of the same name keeps the edit remedy. A rename into `guest` names the create remedy. **Ablation, run at `6499e49c0f` with the fix committed first.** Through `scripts/ablation-replace.mjs`, the line `if (operation === 'create') return heldNameSentence(singular, name);` was replaced with a comment, which collapses a create back to the edit remedy. On disk the anchor count went 1 → 0 and the marker count 0 → 1. Both suites then ran. `metadata-protocol` had 5 failed and 5 passed: every create row flipped to `"create": "edit"`, while the reachability, verdict and removal controls stayed green. `plugin-security` had 3 failed and 41 passed. The restore put the blob back equal to `HEAD` (`a0fda00025d8`) with `git diff HEAD` empty. Neither suite reads `dist/`: the test imports `./protocol.js`, and plugin-security's vitest config aliases `@objectstack/metadata-protocol` to its `src`. So the ablation needed no rebuild. **Runs at `3c3e4ef362`** (this branch after merging `origin/main` `18d999031b`; the merge brought no change to either touched file): - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: `Test Files 224 passed | 3 skipped (227)`, `Tests 28314 passed | 19 skipped (28333)`. - `pnpm --filter @objectstack/plugin-security exec vitest run`: `Test Files 194 passed (194)`, `Tests 4096 passed | 45 skipped (4141)`. - `typecheck` passes for both packages, plugin-security's `check:test-typecheck` included. `tsc --listFiles` counts each touched test file once in its package's program. - The 69 gate commands `dispatch-gates --commands` derives for this diff all exit 0, reconciled with `--ran`: 69 run, 0 NOT MEASURED. One note: `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because 8 unrelated packages had no `dist/`. After building them it exited 0. - eslint, narrowed to the 5 touched TypeScript files, with `--no-inline-config` and `--format json`: 5 files, 0 errors, 0 warnings. Each file is inside the config (`--print-config` resolves). The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The repository-wide `pnpm lint` is CI's. ## Acceptance notes - **Studio's create form still gets the edit remedy until objectui declares its create.** The `/meta` PUT is an upsert, so an unpinned save onto a held name is an edit by the door's contract. Studio's create mode sends no precondition (objectui `ResourceEditPage` create path). Its comment says the door cannot express "expect no row", which has not been true since the door took `If-None-Match: *`. The server half is here. The client half is to send `If-None-Match: *` on create. It is reported for the seat to file in objectui, with this PR as its blocker. - **`POST /automation` onto a packaged flow's name is unchanged.** That door's own contract makes a create onto an existing name an overwrite, which is an edit, so its clone-and-switch remedy matches what it does. `POST /automation/:name/clone` refuses any target name the engine already holds with its own name-taken refusal first (read in `automation.ts`, not probed). - **Code-only types** (`allowRuntimeCreate: false`) are refused at `/meta` by their own emitter (`codeOnlyOverrideError`) before the package door. It says "declare it in source". That is right for a create too, because such a type has no runtime create. - **Clause-② reads `yes`.** `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation. That widens a public input surface, so both packages take `minor`. No verdict, code or status moves. The seat answered the question A and amended its claim (6093560675). Patch round 1 (6094616222, head `caeb7b58f1`) changed the changeset only. - No other region of `protocol.ts` or `sys-metadata-repository.ts` is touched. The seat's #15206 S5 read-path work does not overlap. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8c6666 commit ad14957

6 files changed

Lines changed: 388 additions & 16 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591)
7+
8+
Clause-②: yes
9+
10+
The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves.
11+
12+
A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item.
13+
14+
The remedy now follows what the write did:
15+
16+
- **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type.
17+
- **An edit** keeps the sentence it had, byte for byte.
18+
19+
Which writes are refused does not change, and neither do the code and the status.
20+
21+
Where the remedy is a create's:
22+
23+
- **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create.
24+
- **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before.
25+
- **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way.

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,25 @@
109109
* every package whose items the artifact loader registered is managed, and every
110110
* sentence here names it so: the install mode is what the refusal is about, not
111111
* the fact that the item happens to be code.
112+
*
113+
* ## The remedy follows what the caller DID: an edit, or a create
114+
*
115+
* [#22591] One sealed item is refused for two different acts, and each has its
116+
* own remedy. A save that changes the item under the name (an EDIT) is told
117+
* where that item can change: its row's sanctioned path, or its source. A save
118+
* that brings an item of the caller's own into existence under a name a package
119+
* or a built-in already holds (a CREATE, or a rename into that name) is told the
120+
* one thing it can do instead: choose another name. "Edit the source artifact"
121+
* sends that author to an artifact they did not write and cannot edit, and a
122+
* Regime C path customizes the package's item, which is not what they asked for.
123+
*
124+
* The create sentence is ONE sentence for every type ({@link heldNameSentence}),
125+
* never a row: the remedy does not depend on the type, so a type added later
126+
* gets it without a row, and a row added later cannot take it away. Only the
127+
* opener and the citation are the row's, as in the edit sentences. The act is
128+
* the caller's fact, never inferred here: a door that knows it is taking a name
129+
* says so with the `create` operation ({@link SealedItemOperation}), and every
130+
* other save keeps the edit remedy, byte for byte.
112131
*/
113132

114133
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
@@ -221,9 +240,12 @@ function regimeCPrescription(routes: PackagedBaseRegimeCRoutes): string {
221240
const prescription = paths
222241
.map(([opening, following, rest], i) => (i === 0 ? opening : following) + rest)
223242
.join(', or ');
224-
return `${prescription}. See docs/adr/0126-packaged-metadata-customization-model.md.`;
243+
return `${prescription}. See ${REGIME_C_ADR}.`;
225244
}
226245

246+
/** [ADR-0126 §2] The decision record every Regime C sentence cites. */
247+
const REGIME_C_ADR = 'docs/adr/0126-packaged-metadata-customization-model.md';
248+
227249
/**
228250
* The PRESCRIPTION half of a regime's refusal, built from the row alone: a
229251
* Regime C row's sanctioned paths ({@link regimeCPrescription}), or an
@@ -307,6 +329,49 @@ export function packagedBaseRegimeSentence(
307329
/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
308330
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';
309331

332+
/**
333+
* [#22591] What a refused write did to the item's name — the fact the remedy
334+
* follows (module header, "The remedy follows what the caller DID"):
335+
*
336+
* - `save` — a change to the item already under the name: an edit. The
337+
* default for every save whose caller declares nothing else, so a door
338+
* that cannot tell keeps the remedy it always gave;
339+
* - `create` — a save that brings an item of the caller's own into existence
340+
* under the name: a create, or a rename into the name. Refused on exactly
341+
* the ground, with exactly the code and status, a `save` is;
342+
* - `delete` — a removal.
343+
*/
344+
export type SealedItemOperation = 'save' | 'create' | 'delete';
345+
346+
/**
347+
* [#22591] The sentence for a CREATE under a name a managed package or a
348+
* built-in holds, or a rename into it — ONE sentence for every type (module
349+
* header): who holds the name, the one remedy a create has (choose a name no
350+
* package or built-in holds), where the names in use are listed, and the type's
351+
* own decision record. The opener and the citation are the row's, as in the edit
352+
* sentences — an origin-gated type's item is code-defined, not shipped by a
353+
* managed package; a Regime C type's record is the one whose §2 makes a new
354+
* machine name mandatory for an item authored beside a packaged one.
355+
*
356+
* ⛔ It names neither the source artifact, nor a row's sanctioned path, nor the
357+
* `OS_METADATA_WRITABLE` hatch: all three act on the item that holds the name,
358+
* and the caller asked for an item of their own. The listing route is the
359+
* type's own `/meta` list, which answers the names the type serves.
360+
*
361+
* Kept under the REST door's 500-character client-message bound: outside the
362+
* item's name and its type's three mentions it is 255 characters at most
363+
* (measured; pinned with an 88-character name on the longest type).
364+
*/
365+
function heldNameSentence(singular: string, name: string): string {
366+
const row = packagedBaseRegimeRow(singular);
367+
const holder = row?.regime === 'origin-gated'
368+
? `${row.noun} '${name}' is code-defined`
369+
: `Metadata item '${singular}/${name}' is provided by a managed package`;
370+
const docs = row === undefined ? MANAGED_SEAL_ADR : row.regime === 'C' ? REGIME_C_ADR : row.docs;
371+
return `${holder}, so its name is taken. To author your own ${singular}, choose a name no package or `
372+
+ `built-in holds (GET /api/v1/meta/${singular} lists the names in use). See ${docs}.`;
373+
}
374+
310375
/**
311376
* [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a
312377
* managed package ships, on a type with no environment overlay — every door that
@@ -316,6 +381,10 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org
316381
* the same condition one layer down for the writes that reach it without passing
317382
* a package door (draft promotion, restore, revert).
318383
*
384+
* [#22591] A `create` — a save that takes the name for an item of the caller's
385+
* own — reads {@link heldNameSentence} whatever the type. An edit (`save`) and a
386+
* removal read on:
387+
*
319388
* A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}:
320389
* the sanctioned path, never the hatch). Every other type reads the managed seal
321390
* itself: the item is sealed, its type takes no environment overlay, the
@@ -328,8 +397,9 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org
328397
* Kept under the REST door's 500-character client-message bound: the sentence
329398
* outside the item's type and name is 321 / 275 characters, save / removal (measured).
330399
*/
331-
export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string {
400+
export function managedItemSealedSentence(type: string, name: string, operation: SealedItemOperation): string {
332401
const singular = PLURAL_TO_SINGULAR[type] ?? type;
402+
if (operation === 'create') return heldNameSentence(singular, name);
333403
const regime = packagedBaseRegimeSentence(singular, name, operation);
334404
if (regime !== undefined) return regime;
335405
return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `

0 commit comments

Comments
 (0)