Skip to content

Commit 4d0b9cd

Browse files
fix(metadata-protocol): a packaged flow's refusal names the clone and the on/off switch, not a redeploy or OS_METADATA_WRITABLE (#20819) (#20909)
Fixes #20819 Clause-②: no ## What changes When a write or a removal targets a flow that a code package ships, without naming that package with `?package=`, the package doors in `packages/metadata-protocol` refuse it with `403 NOT_OVERRIDABLE`. (A write that names the shipping package takes the named-base limb and is refused `403 ITEM_LOCKED`, unchanged by this PR; corrected by the seat after review 5916639859.) The refusal's sentence is now chosen **per ADR-0126 regime**, from one table keyed on the type's declared regime: - `PACKAGED_BASE_REGIME` (`protocol.ts:14704`) is the regime declaration. It holds `flow: 'C'` and nothing else. - `PACKAGED_BASE_REFUSAL_BY_REGIME` (`protocol.ts:14732`) is the per-regime sentence table. It has one row, Regime C. - `packagedBaseRegimeSentence` (`protocol.ts:14749`) looks up the table. The two emitters, `refusePackagedBaseOverride` for save and `refusePackagedBaseRemoval` for delete, ask it for their sentence and fall back to their old literal, which is kept byte for byte. There is no `type === 'flow'` branch in the emitters. The Regime C sentence, as served by `PUT /api/v1/meta/flow/pkg_alert_flow`: ```text Metadata item 'flow/pkg_alert_flow' is provided by a code package, and its packaged base is locked against in-place edits. Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; operator-only where one install serves several organizations). See docs/adr/0126-packaged-metadata-customization-model.md. ``` On removal, "against in-place edits" reads "against removal". The sentence names the clone (§7.1) and the switch (§7.2), cites ADR-0126, and does not mention `OS_METADATA_WRITABLE` or a redeploy. **What does not move** (Clause-② `no`): the code, the status, and which writes are refused. The predicate is unchanged, and `OS_METADATA_WRITABLE=flow` still opens the lock at both doors (the existing hatch pin stays green; only its comment changed). The named-base `ITEM_LOCKED` limb is unchanged. Every type with no declared regime keeps its sentence byte for byte. ## Zone 2 readings (PM hypotheses) - **H1 held.** `packagedBaseRefusal` is at `protocol.ts:14513` (about `:14506` at base `33b6e8bec`). Every door reaches the same two emitters, and every caller relays the sentence unchanged: - `saveMetaItem` / `deleteMetaItem` call the emitters directly, behind `environmentId !== undefined`. - `tenantAuthoredWriteRefusal` returns `packagedBaseRefusal` for a flow in the loader's set. It is asked by `saveMetaItem` (the `/meta` PUT, on both topologies) and by runtime `refuseUnauthoredFlowWrite` (`POST /automation`, `PUT /automation/:name`, and the clone target). The runtime relays it through `deps.errorFromThrown(refusal)`. - runtime `refusePackagedFlowBaseChange` covers `DELETE /automation/:name`, and PUT/POST when the protocol has no authoring rule. It relays through `deps.errorFromThrown(refusal)`. - The REST `/meta` routes relay it through `handleRouteError`, verbatim below 500 characters. - The runtime pin "ONE emitter" compares the messages across the doors, and they are equal. - **H2: no regime is declared in code.** `git grep -i regime` over `packages/{spec,metadata-protocol,metadata-core,runtime,core,types}/src` finds prose comments only. `DEFAULT_METADATA_TYPE_REGISTRY` entries carry no regime field. So the smallest declaration went in beside the door: a map from type to regime, holding Regime C = `flow`. - ADR-0126 §3's full Regime C list: `flow`; `permission`; `action` (the disable half; the clone half is not chartered, §8 item 2); pre-charted `tool`, `skill` and `position`. - Two other Regime C types reach this door. `action` reaches it on save and delete. `permission` reaches it on save only: its delete is exempted by the `supportsOverlay` carve-out. - Neither is declared. The Regime C sentence names the `/automation` routes, which exist for a flow alone. `action`'s switch is `POST /api/v1/actions/_activation/:object/:action` and it has no clone; `permission` has its own clone machinery. - `tool`, `skill` and `position` reach the door only if a package ships one. The measurement recorded in `isNestedArtifactField`'s comment found none on the showcase. - The `action` control pin holds the undeclared sentence byte-identical. Measured at the REST route, see the Acceptance notes. - **H3 held.** Both routes are served on `main`: `POST /automation/:name/toggle` at `automation.ts:2427` and `POST /automation/:name/clone` at `automation.ts:2513`, under the `/automation` domain prefix. - The toggle is gated by `manage_metadata`, then by `refuseUngrantedActivationWrite`, which requires the platform operator in the `group` / `isolated` postures (ADR-0126 §5). - The clone body `{ name, label }` needs both keys. The toggle body is `{ enabled?: boolean }`. - The runtime test's "what the lock leaves open" cases drive both routes to `200` on a packaged flow. - **H4 applies, and it holds.** `CLIENT_MESSAGE_MAX = 500` (`packages/rest/src/error-response.ts:72`) truncates the tail at 500 characters. - The sentence is 411 characters before the name on save and 404 on removal: 425 and 418 with `pkg_alert_flow`. Any name up to 88 characters arrives whole. - The protocol pin asserts a length under 500. The REST pin asserts the served text still ends with the ADR citation. ## Tests (all at head `d58d5578f6` unless stated) - `packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts`, with 8 new cases: - flow save and delete, on both topologies: 403 `NOT_OVERRIDABLE`, clone and toggle named, ADR-0126 cited, no hatch, no redeploy, under 500 characters; - the plural spelling folds to the same sentence; - the `/meta` doors throw the regime sentence; - control: `page` keeps its sentence byte for byte; - control: `action` keeps its save and delete sentences byte for byte. Result: `Tests 17 passed (17)`. - `packages/runtime/src/domains/automation-packaged-base-lock.test.ts`: `PUT` and `DELETE /automation/:name` (both topologies) and `POST /automation` onto a packaged name now also assert that the relayed message names clone and toggle and does not name the hatch. The hatch test's title and comment are corrected: the refusal no longer names the hatch, and the hatch still opens the door. `Tests 15 passed (15)`. - `packages/rest/src/rest-meta-packaged-flow-refusal.test.ts` (new) drives the real `PUT` / `DELETE /api/v1/meta/:type/:name` routes over a real protocol and `SchemaRegistry`: flow PUT on both topologies, flow DELETE on an environment kernel, and a `page` control compared byte for byte. `Tests 4 passed (4)`. - Full `@objectstack/metadata-protocol` suite, run at `af8a9ee7a9` (the merge since then brought no change to this package): `Test Files 193 passed | 3 skipped (196)`, `Tests 2870 passed | 19 skipped (2889)`. - Typecheck at `af8a9ee7a9`: `pnpm --filter` `typecheck` passes for `@objectstack/metadata-protocol`, `@objectstack/rest` and `@objectstack/runtime`. `tsc --listFiles` counts each of the three test files once in its package's program. `check:test-typecheck` is OK for rest (0 held) and runtime (the identity-pinned ledger is unchanged). - The `packages/cli` tiers were not touched. The runtime and rest full suites are left to CI; this PR changes only the one test file in each. ## Ablation on the regime choice (committed fix first; `node scripts/ablation-replace.mjs` with its restore trap, `pnpm --filter @objectstack/metadata-protocol build`, then `node scripts/ablation-dist-preflight.mjs`; rest and runtime read the `dist/`) Predicted before running: leg A turns the flow pins red and leaves the controls green; leg B turns only the `page` controls red. Both came out as predicted. | leg | on-disk proof | dist preflight | metadata-protocol | runtime | rest | |:--|:--|:--|:--|:--|:--| | A: delete `flow: 'C',` | anchor 1→0, blob `f1e6267e`→`787e1532` | marker absent from all 24 built files | 5 failed / 12 passed | 5 failed / 10 passed | 3 failed / 1 passed | | A restore | blob == HEAD `f1e6267e`, `git diff HEAD` empty | marker present in 2 built files; tree clean | 17 passed | 15 passed | 4 passed | | B: declare `page: 'C'` | anchor 1→0, blob `f1e6267e`→`fbb29b3b` | marker present in 2 built files | 1 failed (`page` control) / 16 passed | 15 passed | 1 failed (`page` control) / 3 passed | | B restore | blob == HEAD, `git status --porcelain` empty | marker absent from all 24; tree clean | 17 passed | 15 passed | 4 passed | The first leg-B attempt was a no-op: `ablation-replace` refused it because the replacement contained the anchor ("anchor count moved 1 → 1") and restored the file. It was re-run with a non-overlapping anchor, and the row above is that run. Both runs were at `af8a9ee7a9`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` (no paths), at `d58d5578f6`, derived 63 commands. The change set is 5 paths, 366 changed lines against the 5000-line human-merge threshold: under. All 63 were run at `d58d5578f6`, and `--ran` reports: `✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (0 DERIVED from a recorded exit 3, 2 claimed)`. - 61 exited 0, including `check:doc-authoring` (the new sentence carries no tracker number), `check:nul-bytes`, `check:engine-double-contract`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:issue-citations` and `check:durability-log-level`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, reason: PREREQUISITE NOT MET (exit 3). Both read the whole workspace's built output. A full packages build is 50 uncached turbo tasks, `@objectstack/console` included (dry run), which is beyond the shared box's foreground cap. CI's lint.yml builds that closure first. - The branch merged `origin/main` (`d58d5578f6`) because the first derivation warned of a stale tree (`scripts/pm/check-governed-queue-guard.mjs` had moved). The incoming commits touch `driver-memory` and that script only. The re-derivation printed the same 63 commands with no stale warning. The build closure was refreshed and the three pinned files re-run at `d58d5578f6` (17 / 15 / 4 passed). Lint was narrowed to the changed files, not the repo-wide `pnpm lint` (that run is CI's). Three pieces of evidence make that narrowing complete: 1. The population comes from eslint's own config: `eslint --no-inline-config --print-config` resolves a config for each of the 4 changed `.ts` files, so none is ignored. 2. `eslint --no-inline-config --format json` on those files counts `files 4 errors 0 warnings 0`, at `d58d5578f6`. 3. `eslint.config.mjs` never enables type-aware linting ("no `parserOptions.project`, no typed `@typescript-eslint` rules", at its line 328), so this diff cannot move the verdict on any file it did not touch. ## Acceptance notes - **Other Regime C types keep the hatch sentence (measured; not filed here).** ADR-0126 §3 also puts `action` and `permission` in Regime C. Driving the real REST `/meta` route in-process (real protocol, registry double, environment kernel) gives these answers: - `PUT /api/v1/meta/action/pkg_approve` answers `403 NOT_OVERRIDABLE` with "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. See docs/adr/0005-…"; - `PUT /api/v1/meta/permission/pkg_perm` gives the same answer; - `DELETE` on the action answers the removal sentence, which names no path at all. The ruling scoped this card to `flow`. Their own sanctioned paths differ (see H2), so they need their own table rows. That is left to the seat, together with #15206, which owns whether the hatch survives for sealed managed content. - **The named-base `ITEM_LOCKED` limb** (`SysMetadataRepository.readOnlyBaseOverrideError`, `sys-metadata-repository.ts:1745`, shared with the repository door) still prescribes "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=TYPE" for any type, flow included, and cites ADR-0010. It is reached by `PUT /api/v1/meta/flow/:name?package=PKG` when PKG is read-only. Reach NOT MEASURED: the harness's engine double reports every package as writable, so the probe got this PR's `NOT_OVERRIDABLE` sentence instead. It is outside this card's file surface. Carrier: none. - `orgScopedWriteRefusal` and the code-only refusals also name the hatch. They answer different conditions (an org-scoped write of any flow; code-only types), and whether the hatch survives is #15206's question. ## Deviations - Commit trailers use the model-free pair from AGENTS.md, not the model-bearing trailer the harness suggested. - One merge commit (`d58d5578f6`) from `origin/main`; see Gates. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent def279a commit 4d0b9cd

5 files changed

Lines changed: 357 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the refusal of an in-place edit or removal of a packaged flow names the clone and the on/off switch, not a redeploy or `OS_METADATA_WRITABLE` (#20819)
6+
7+
Clause-②: no
8+
9+
A write or removal that targets a flow shipped by a code package, and does not name that package, is refused with `403 NOT_OVERRIDABLE`. That covers `PUT /api/v1/meta/flow/:name` without `?package=`, `DELETE /api/v1/meta/flow/:name`, `PUT` and `DELETE /api/v1/automation/:name`, and `POST /api/v1/automation` onto a packaged flow's name. The refusal used to say "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch", and cited ADR-0005. The administrator of an installed package can do neither.
10+
11+
The refusal now names the two paths ADR-0126 sanctions for a packaged flow, and cites ADR-0126:
12+
13+
- clone it under a new name to customize it: `POST /api/v1/automation/:name/clone` with `{ name, label }`;
14+
- or switch it off: `POST /api/v1/automation/:name/toggle` with `{ enabled: false }`. Where one install serves several organizations, only the platform operator can use the switch.
15+
16+
The status, the code and which writes are refused are unchanged. `OS_METADATA_WRITABLE=flow` still opens the lock as before; the refusal just no longer suggests it. Every other metadata type's refusal reads exactly as before. A write that names the shipping package with `?package=` is refused with `403 ITEM_LOCKED` by a separate limb, which this change leaves as it was.

‎packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ const PACKAGE_ID = 'com.example.pkg';
3030
const shipped = (name: string, extra: Record<string, unknown> = {}) =>
3131
({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package', ...extra });
3232

33-
/** Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`). */
33+
/**
34+
* Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`) —
35+
* plus `action`, which ADR-0126 §3 also puts in Regime C but whose sanctioned paths are not the flow's.
36+
*/
3437
const ARTIFACTS = new Map<string, Map<string, unknown>>([
3538
['flow', new Map([['pkg_flow', shipped('pkg_flow', { type: 'autolaunched', nodes: [], edges: [] })]])],
3639
['view', new Map([['pkg_view', shipped('pkg_view')]])],
3740
['page', new Map([['pkg_page', shipped('pkg_page')]])],
41+
['action', new Map([['pkg_action', shipped('pkg_action')]])],
3842
]);
3943

4044
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
@@ -119,3 +123,89 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed
119123
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull();
120124
});
121125
});
126+
127+
/**
128+
* [#20819, ADR-0126 §2] The refusal's SENTENCE is chosen per regime, from one
129+
* table keyed on the type's declared regime. The code, the status and which
130+
* writes are refused are the matrix above, unchanged.
131+
*
132+
* A Regime C type (`flow`) names Regime C's sanctioned paths — the clone under
133+
* a new name (§7.1) and the enable/disable switch (§7.2) — cites ADR-0126, and
134+
* names neither the `OS_METADATA_WRITABLE` hatch nor a redeploy an installed
135+
* package's administrator cannot make. Every other type's sentence is
136+
* byte-identical to what it was (the controls): `page` has no regime, and
137+
* `action` is Regime C on paper but its sanctioned paths are not the flow's
138+
* routes, so it keeps its sentence until its own paths are declared.
139+
*/
140+
describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', () => {
141+
const CLONE = 'POST /api/v1/automation/:name/clone';
142+
const TOGGLE = 'POST /api/v1/automation/:name/toggle';
143+
const ADR_0126 = 'docs/adr/0126-packaged-metadata-customization-model.md';
144+
145+
/** The package-less sentence every regime-less type keeps (`refusePackagedBaseOverride`). */
146+
const LEGACY_SAVE = (type: string, name: string) =>
147+
`Metadata item '${type}/${name}' is provided by a code package `
148+
+ 'and the type has not opted into per-org overlay writes (allowOrgOverride=false). '
149+
+ 'Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. '
150+
+ 'See docs/adr/0005-metadata-customization-overlay.md.';
151+
/** …and its removal twin (`refusePackagedBaseRemoval`). */
152+
const LEGACY_DELETE = (type: string, name: string) =>
153+
`Metadata item '${type}/${name}' is provided by a code package `
154+
+ 'and the type has not opted into per-org overlay writes. '
155+
+ 'See docs/adr/0005-metadata-customization-overlay.md.';
156+
157+
for (const environmentId of [undefined, 'env_1']) {
158+
for (const operation of ['save', 'delete'] as const) {
159+
it(`a packaged flow's ${operation} refusal names clone and the switch, cites ADR-0126, and names no hatch `
160+
+ `(${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
161+
const refusal: any = protocolOn(environmentId)
162+
.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation });
163+
expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
164+
const message = String(refusal.message);
165+
expect(message.startsWith("Metadata item 'flow/pkg_flow' is provided by a code package")).toBe(true);
166+
expect(message).toContain(CLONE);
167+
expect(message).toContain(TOGGLE);
168+
expect(message).toContain(ADR_0126);
169+
expect(message).not.toContain('OS_METADATA_WRITABLE');
170+
expect(message).not.toContain('redeploy');
171+
expect(message).not.toContain('0005-metadata-customization-overlay');
172+
// The REST door truncates a client message at 500 characters;
173+
// the whole sentence, ADR citation included, must arrive.
174+
expect(message.length).toBeLessThan(500);
175+
});
176+
}
177+
}
178+
179+
it('the regime is read on the canonical type — a plural spelling gets the same sentence', () => {
180+
const p = protocolOn(undefined);
181+
const plural: any = p.packagedBaseRefusal({ type: 'flows', name: 'pkg_flow', operation: 'save' });
182+
const singular: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' });
183+
expect(plural.message).toBe(singular.message);
184+
});
185+
186+
it('the /meta doors throw the same regime sentence the value carries (one emitter)', async () => {
187+
const p = protocolOn('env_1');
188+
const thrownBy = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);
189+
const save: any = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } }));
190+
const del: any = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' }));
191+
expect(save?.message).toContain(CLONE);
192+
expect(del?.message).toContain(TOGGLE);
193+
});
194+
195+
it('control: a type with no declared regime (`page`) keeps its sentence byte for byte', () => {
196+
const p = protocolOn('env_1');
197+
const refusal: any = p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' });
198+
expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
199+
expect(refusal.message).toBe(LEGACY_SAVE('page', 'pkg_page'));
200+
});
201+
202+
it('control: `action` — Regime C on paper, with sanctioned paths that are not the flow\'s — keeps both sentences byte for byte', () => {
203+
const p = protocolOn('env_1');
204+
const save: any = p.packagedBaseRefusal({ type: 'action', name: 'pkg_action', operation: 'save' });
205+
const del: any = p.packagedBaseRefusal({ type: 'action', name: 'pkg_action', operation: 'delete' });
206+
expect(shape(save)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
207+
expect(shape(del)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
208+
expect(save.message).toBe(LEGACY_SAVE('action', 'pkg_action'));
209+
expect(del.message).toBe(LEGACY_DELETE('action', 'pkg_action'));
210+
});
211+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4798,6 +4798,13 @@ function isNonCanonicalStoredType(type: string): boolean {
47984798
return canonicalMetaType(type) !== type && (PLURAL_TO_SINGULAR[type] ?? type) === type;
47994799
}
48004800

4801+
/**
4802+
* [#20819] An ADR-0126 customization regime the packaged-base refusal speaks
4803+
* for — only the regimes it needs today (see
4804+
* `ObjectStackProtocolImplementation.PACKAGED_BASE_REGIME`).
4805+
*/
4806+
type PackagedBaseRegime = 'C';
4807+
48014808
/**
48024809
* Implements the per-domain contracts this class ACTUALLY provides (ADR-0076
48034810
* D10 — the facade never implemented the other domains; those live in their
@@ -14669,6 +14676,85 @@ export class ObjectStackProtocolImplementation implements
1466914676
return status === 403 && (code === 'NOT_OVERRIDABLE' || code === 'ITEM_LOCKED');
1467014677
}
1467114678

14679+
/**
14680+
* [#20819, ADR-0126 §2 / §3] The customization REGIME of a metadata type,
14681+
* declared for the types whose packaged-base refusal speaks for its regime.
14682+
*
14683+
* ADR-0126 §2 requires a Regime C refusal to name the sanctioned path —
14684+
* "in-place edit refused loudly at the write door, the refusal naming the
14685+
* sanctioned path" — and §10 gives the reason: a locked base that refuses
14686+
* with its sanctioned path in the message is what keeps AI-written metadata
14687+
* from guessing. The regime is recorded nowhere else in code (the ADR's
14688+
* table is prose, and the registry entries carry no regime field), so this
14689+
* map is its one declaration for the refusal, and
14690+
* {@link PACKAGED_BASE_REFUSAL_BY_REGIME} is keyed off it — never a second
14691+
* list of type names, and never a type branch in the emitters' prose.
14692+
*
14693+
* It carries `flow` only, deliberately, though ADR-0126 §3's Regime C row is
14694+
* longer — `flow`, `permission`, `action`, and pre-charted `tool` / `skill`
14695+
* / `position`. A type is declared here only once the Regime C sentence's
14696+
* paths are ITS served paths: that sentence names the `/automation` clone
14697+
* and switch routes, which exist for a flow alone. `action` has a switch at
14698+
* its own route and no clone at all (the action-clone half is not
14699+
* chartered, §8 item 2), and `permission` has its own clone machinery, so
14700+
* declaring either would advertise a route that does not exist for it —
14701+
* both keep the sentence they had until their own sanctioned paths are
14702+
* written into the table.
14703+
*/
14704+
private static readonly PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegime>> = {
14705+
flow: 'C',
14706+
};
14707+
14708+
/**
14709+
* [#20819, ADR-0126 §2 / §7] The packaged-base refusal's sentence, chosen
14710+
* PER REGIME ({@link PACKAGED_BASE_REGIME}). A type with no declared regime
14711+
* keeps the sentence the emitters carry inline, byte for byte.
14712+
*
14713+
* Regime C (§2: behavioral — locked base, disable, clone-as-sibling) names
14714+
* its two sanctioned paths and cites the ADR that decided them:
14715+
*
14716+
* - **clone under a new name** (§7.1) — `POST /automation/:name/clone`,
14717+
* whose body `{ name, label }` requires both keys;
14718+
* - **the enable/disable switch** (§7.2) — `POST /automation/:name/toggle`,
14719+
* body `{ enabled }`, operator-gated per §5 where one install serves
14720+
* several organizations (the `group` / `isolated` postures).
14721+
*
14722+
* ⛔ It does not name the `OS_METADATA_WRITABLE` hatch. The hatch still
14723+
* opens this lock exactly as before ({@link isOverlayAllowed}), so which
14724+
* writes are refused does not move — only what the refusal prescribes.
14725+
* ⛔ Nor does it prescribe editing the source and redeploying: the
14726+
* administrator of an installed package cannot do that.
14727+
*
14728+
* Kept under the REST door's 500-character client-message bound, past
14729+
* which the tail is truncated: 411 characters before the item's name on
14730+
* save and 404 on removal, so a name of up to 88 characters arrives whole.
14731+
*/
14732+
private static readonly PACKAGED_BASE_REFUSAL_BY_REGIME: Readonly<
14733+
Record<PackagedBaseRegime, (type: string, name: string, operation: 'save' | 'delete') => string>
14734+
> = {
14735+
C: (type, name, operation) =>
14736+
`Metadata item '${type}/${name}' is provided by a code package, and its packaged base is locked `
14737+
+ (operation === 'delete' ? `against removal. ` : `against in-place edits. `)
14738+
+ `Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), `
14739+
+ `or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; `
14740+
+ `operator-only where one install serves several organizations). `
14741+
+ `See docs/adr/0126-packaged-metadata-customization-model.md.`,
14742+
};
14743+
14744+
/**
14745+
* The regime-chosen refusal sentence for `(type, name, operation)`, or
14746+
* `undefined` when the type declares no regime and the emitter keeps its
14747+
* own sentence.
14748+
*/
14749+
private static packagedBaseRegimeSentence(
14750+
type: string, name: string, operation: 'save' | 'delete',
14751+
): string | undefined {
14752+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
14753+
const regimes = ObjectStackProtocolImplementation.PACKAGED_BASE_REGIME;
14754+
if (!Object.prototype.hasOwnProperty.call(regimes, singular)) return undefined;
14755+
return ObjectStackProtocolImplementation.PACKAGED_BASE_REFUSAL_BY_REGIME[regimes[singular]](singular, name, operation);
14756+
}
14757+
1467214758
/**
1467314759
* [#8184] THE PACKAGE DOOR — `saveMetaItem`'s refusal of a write onto an
1467414760
* item a code package ships, on a type with no per-org overlay channel.
@@ -14682,6 +14768,10 @@ export class ObjectStackProtocolImplementation implements
1468214768
* refusals, above the ADR-0010 `_lock` check). The one added line computes
1468314769
* `overlayAllowed` the way `saveMetaItem` computes it at its top. In the
1468414770
* record, "the block comment above" and "this method" mean `saveMetaItem`.
14771+
*
14772+
* [#20819] Since lifted, the package-less `NOT_OVERRIDABLE` SENTENCE is
14773+
* chosen per ADR-0126 regime ({@link packagedBaseRegimeSentence}); the
14774+
* predicate, the code, the status and the named-base limb are unchanged.
1468514775
*/
1468614776
private refusePackagedBaseOverride(
1468714777
request: { type: string; name: string; packageId?: string | null },
@@ -14757,11 +14847,14 @@ export class ObjectStackProtocolImplementation implements
1475714847
request.type, request.packageId as string, false,
1475814848
);
1475914849
}
14850+
// [#20819] The SENTENCE is chosen per ADR-0126 regime; the code,
14851+
// the status and this branch's predicate are unchanged.
1476014852
const err = new Error(
14761-
`Metadata item '${request.type}/${request.name}' is provided by a code package `
14853+
ObjectStackProtocolImplementation.packagedBaseRegimeSentence(request.type, request.name, 'save')
14854+
?? (`Metadata item '${request.type}/${request.name}' is provided by a code package `
1476214855
+ `and the type has not opted into per-org overlay writes (allowOrgOverride=false). `
1476314856
+ `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. `
14764-
+ `See docs/adr/0005-metadata-customization-overlay.md.`
14857+
+ `See docs/adr/0005-metadata-customization-overlay.md.`)
1476514858
);
1476614859
(err as any).code = 'NOT_OVERRIDABLE';
1476714860
(err as any).status = 403;
@@ -14784,17 +14877,23 @@ export class ObjectStackProtocolImplementation implements
1478414877
* lines, byte for byte apart from indentation. The two added lines compute
1478514878
* `overlayAllowed` and `artifactBacked` the way `deleteMetaItem` computes
1478614879
* them, where both still stand for its `NOT_CREATABLE` check.
14880+
*
14881+
* [#20819] Since lifted, the SENTENCE is chosen per ADR-0126 regime
14882+
* ({@link packagedBaseRegimeSentence}); the predicate, the code and the
14883+
* status are unchanged.
1478714884
*/
1478814885
private refusePackagedBaseRemoval(request: { type: string; name: string }): void {
1478914886
const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type);
1479014887
const artifactBacked = this.isArtifactBacked(request.type, request.name);
1479114888
const legacyOverlayRemoval = ObjectStackProtocolImplementation
1479214889
.mergesOverlayAtRead(request.type);
1479314890
if (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) {
14891+
// [#20819] Sentence per ADR-0126 regime, as in the save door.
1479414892
const err = new Error(
14795-
`Metadata item '${request.type}/${request.name}' is provided by a code package `
14893+
ObjectStackProtocolImplementation.packagedBaseRegimeSentence(request.type, request.name, 'delete')
14894+
?? (`Metadata item '${request.type}/${request.name}' is provided by a code package `
1479614895
+ `and the type has not opted into per-org overlay writes. `
14797-
+ `See docs/adr/0005-metadata-customization-overlay.md.`
14896+
+ `See docs/adr/0005-metadata-customization-overlay.md.`)
1479814897
);
1479914898
(err as any).code = 'NOT_OVERRIDABLE';
1480014899
(err as any).status = 403;

0 commit comments

Comments
 (0)