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
25 changes: 25 additions & 0 deletions .changeset/22591-sealed-remedy-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/plugin-security': minor
---

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)

Clause-②: yes

The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves.

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.

The remedy now follows what the write did:

- **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.
- **An edit** keeps the sentence it had, byte for byte.

Which writes are refused does not change, and neither do the code and the status.

Where the remedy is a create's:

- **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.
- **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.
- **`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.
74 changes: 72 additions & 2 deletions packages/metadata-protocol/src/packaged-base-regime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@
* every package whose items the artifact loader registered is managed, and every
* sentence here names it so: the install mode is what the refusal is about, not
* the fact that the item happens to be code.
*
* ## The remedy follows what the caller DID: an edit, or a create
*
* [#22591] One sealed item is refused for two different acts, and each has its
* own remedy. A save that changes the item under the name (an EDIT) is told
* where that item can change: its row's sanctioned path, or its source. A save
* that brings an item of the caller's own into existence under a name a package
* or a built-in already holds (a CREATE, or a rename into that name) is told the
* one thing it can do instead: choose another name. "Edit the source artifact"
* sends that author to an artifact they did not write and cannot edit, and a
* Regime C path customizes the package's item, which is not what they asked for.
*
* The create sentence is ONE sentence for every type ({@link heldNameSentence}),
* never a row: the remedy does not depend on the type, so a type added later
* gets it without a row, and a row added later cannot take it away. Only the
* opener and the citation are the row's, as in the edit sentences. The act is
* the caller's fact, never inferred here: a door that knows it is taking a name
* says so with the `create` operation ({@link SealedItemOperation}), and every
* other save keeps the edit remedy, byte for byte.
*/

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

/** [ADR-0126 §2] The decision record every Regime C sentence cites. */
const REGIME_C_ADR = 'docs/adr/0126-packaged-metadata-customization-model.md';

/**
* The PRESCRIPTION half of a regime's refusal, built from the row alone: a
* Regime C row's sanctioned paths ({@link regimeCPrescription}), or an
Expand Down Expand Up @@ -307,6 +329,49 @@ export function packagedBaseRegimeSentence(
/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';

/**
* [#22591] What a refused write did to the item's name — the fact the remedy
* follows (module header, "The remedy follows what the caller DID"):
*
* - `save` — a change to the item already under the name: an edit. The
* default for every save whose caller declares nothing else, so a door
* that cannot tell keeps the remedy it always gave;
* - `create` — a save that brings an item of the caller's own into existence
* under the name: a create, or a rename into the name. Refused on exactly
* the ground, with exactly the code and status, a `save` is;
* - `delete` — a removal.
*/
export type SealedItemOperation = 'save' | 'create' | 'delete';

/**
* [#22591] The sentence for a CREATE under a name a managed package or a
* built-in holds, or a rename into it — ONE sentence for every type (module
* header): who holds the name, the one remedy a create has (choose a name no
* package or built-in holds), where the names in use are listed, and the type's
* own decision record. The opener and the citation are the row's, as in the edit
* sentences — an origin-gated type's item is code-defined, not shipped by a
* managed package; a Regime C type's record is the one whose §2 makes a new
* machine name mandatory for an item authored beside a packaged one.
*
* ⛔ It names neither the source artifact, nor a row's sanctioned path, nor the
* `OS_METADATA_WRITABLE` hatch: all three act on the item that holds the name,
* and the caller asked for an item of their own. The listing route is the
* type's own `/meta` list, which answers the names the type serves.
*
* Kept under the REST door's 500-character client-message bound: outside the
* item's name and its type's three mentions it is 255 characters at most
* (measured; pinned with an 88-character name on the longest type).
*/
function heldNameSentence(singular: string, name: string): string {
const row = packagedBaseRegimeRow(singular);
const holder = row?.regime === 'origin-gated'
? `${row.noun} '${name}' is code-defined`
: `Metadata item '${singular}/${name}' is provided by a managed package`;
const docs = row === undefined ? MANAGED_SEAL_ADR : row.regime === 'C' ? REGIME_C_ADR : row.docs;
return `${holder}, so its name is taken. To author your own ${singular}, choose a name no package or `
+ `built-in holds (GET /api/v1/meta/${singular} lists the names in use). See ${docs}.`;
}

/**
* [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a
* managed package ships, on a type with no environment overlay — every door that
Expand All @@ -316,6 +381,10 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org
* the same condition one layer down for the writes that reach it without passing
* a package door (draft promotion, restore, revert).
*
* [#22591] A `create` — a save that takes the name for an item of the caller's
* own — reads {@link heldNameSentence} whatever the type. An edit (`save`) and a
* removal read on:
*
* A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}:
* the sanctioned path, never the hatch). Every other type reads the managed seal
* itself: the item is sealed, its type takes no environment overlay, the
Expand All @@ -328,8 +397,9 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org
* Kept under the REST door's 500-character client-message bound: the sentence
* outside the item's type and name is 321 / 275 characters, save / removal (measured).
*/
export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string {
export function managedItemSealedSentence(type: string, name: string, operation: SealedItemOperation): string {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
if (operation === 'create') return heldNameSentence(singular, name);
const regime = packagedBaseRegimeSentence(singular, name, operation);
if (regime !== undefined) return regime;
return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `
Expand Down
Loading
Loading