Skip to content

Commit 0c5a71b

Browse files
fix(metadata-protocol): a packaged action or permission set's refusal names its own sanctioned path (ADR-0126 Regime C rows) (#21026)
Fixes #20910 Clause-②: no ## What this changes ADR-0126 §3 puts `flow`, `action` and `permission` in Regime C, and §2 requires the Regime C refusal to name the sanctioned path. Before this PR, only `flow`'s package-less refusal at the environment-scoped package door did that. Every other refusal of an in-place write onto one of these packaged items prescribed the `OS_METADATA_WRITABLE` hatch or a redeploy, and a packaged action's removal named no path at all. This PR follows triage's direction on the card (one regime, per-type sanctioned routes, the sentence built from the row) and the seat answer on Q1 and Q2 (comment 5923536089): - **One table, one new module.** `packages/metadata-protocol/src/packaged-base-regime.ts` holds `PACKAGED_BASE_REGIME`. Each row is `{ regime, routes }`, and `routes` declares only the primitives the type has: `clone`, `switchOff`, or both. A row that names neither does not compile. The module builds the prescription (the row's paths in a fixed order, clone then switch, then the ADR-0126 citation) and the whole package-door sentence. Nothing in it reads the type. - The module is not exported from the package entry. - It lives in its own module because `protocol.ts` imports `sys-metadata-repository.ts`, so the table could not live in either file without an import cycle. - **Rows:** - `flow`: clone (`POST /api/v1/automation/:name/clone`) and switch (`POST /api/v1/automation/:name/toggle`). - `action`: the switch only, `POST /api/v1/actions/_activation/:object/:action` with `{enabled: false}`, where `:object` is `global` for an object-less action. No clone, because the action-clone half is not chartered. Its operator-only clause matches the flow toggle's, since both routes run the one ADR-0126 §5 gate. - `permission`: the clone only, spelled as `plugin-security`'s own lock refusal spells it. - **Three doors read it. No door has a per-type prose branch.** 1. The protocol's package door (`refusePackagedBaseOverride` / `refusePackagedBaseRemoval`), on an environment-scoped kernel, answers `403 NOT_OVERRIDABLE`. `flow`'s sentence is byte-identical, held by a literal pin. 2. **The repository's type door** (`SysMetadataRepository.assertAllowed`) is where a write lands when the `/meta` protocol is not environment-scoped, which includes the default `pnpm dev` boot. For an override-artifact write with the hatch closed, on a type with a row, it now answers the same row-built sentence, `403 NOT_OVERRIDABLE`, on save and on removal. Every other type keeps the overlay-allowed-list sentence and its pins. 3. **The named-base `ITEM_LOCKED` limb** (`readOnlyBaseOverrideError`) applies with the hatch closed, on a type with a row. Its opener is shortened to "Cannot overlay 'TYPE' in package 'ID': that package is read-only, and its packaged base is locked against in-place edits.", followed by the row's prescription and the ADR-0126 citation. - Unchanged: the hatch-OPEN remedy, `code`, `status`, `lockSource`, `packageId` and `docs`. - Unchanged: NARROW. A package-less hatch write still lands, and that is pinned. - **The #8146 hatch-closed pin moved and was not deleted.** It now sits on `page`, a type with no regime row, and is measured to reach the same limb (`ITEM_LOCKED`, `lockSource: 'package'`, the named base echoed back). A Regime C pin sits beside it, and the pin's comment cites ADR-0126 §2 as the reason the two sentences differ. - **Docs.** `content/docs/concepts/metadata-lifecycle.mdx` had two sentences that contradicted the new text, and both are corrected: - "a create/update aimed at a read-only package … is asked to pick or create a writable base"; - the `permission` row's "edit the package and re-publish". - A short "Packaged flows, actions and permission sets (ADR-0126)" subsection lists each type's sanctioned path. No code, status, route or refused write moves. **The `Clause-②: no` check** (the seat answer's stop (a)). `assertAllowed` / `assertDeleteAllowed` now take the ref and the operation, and both are `private`. `readOnlyBaseOverrideError`'s signature is unchanged. Measured on the built declarations, with comments stripped, before against after this round (`dist/index.d.ts` and `dist/index.d.cts`): the only lines that differ are the removal of three `private static` member names on `ObjectStackProtocolImplementation` (`PACKAGED_BASE_REGIME`, `PACKAGED_BASE_REFUSAL_BY_REGIME`, `packagedBaseRegimeSentence`), which the table move took out of the class. No exported signature moved, and the new module appears nowhere in the declarations. **The seat answer's stop (b)**: no type without a regime row changes its sentence at any door. The controls are `page` and `object` here, and `page` on the booted app below. **Lengths** against the REST door's 500-character bound (`truncateClientMessage` keeps a message only while it is shorter than 500): | Refusal | Row | Characters before the variable part | Longest variable part that arrives whole | Pin | |:--|:--|:--|:--|:--| | Package door / type door (save / removal) | `flow` | 411 / 404 before the name | 88-character name | yes | | Package door / type door (save / removal) | `action` | 365 / 358 before the name | 88-character name | yes | | Package door / type door (save) | `permission` | 317 before the name | 88-character name | yes | | `ITEM_LOCKED` named base | `flow` | 422 before the package id | 77-character package id | 64-character id | | `ITEM_LOCKED` named base | `action` | 376 before the package id | 123-character package id | 64-character id | | `ITEM_LOCKED` named base | `permission` | 328 before the package id | 171-character package id | 64-character id | The longest package id this repository ships is `com.objectstack.platform-objects.activation-ledger`, at 50 characters. ## Measured **Booted showcase** (`pnpm dev -- --fresh`, local mode, discovery `scoping.enabled: false`). Before is the previous head of this PR, `d9b1a8e8b`, byte-identical to `origin/main` `7fa67dada`. After is `9c169378a`. | request | before | after | |:--|:--|:--| | `PUT /api/v1/meta/flow/showcase_task_completed?package=com.example.showcase` | `403 ITEM_LOCKED` "…locked against runtime edits. Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=flow to grant a runtime escape hatch on this TYPE (…pair it with a package-less write). See docs/adr/0010-…" | `403 ITEM_LOCKED` "Cannot overlay 'flow' in package 'com.example.showcase': that package is read-only, 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." | | `PUT /api/v1/meta/action/showcase_mark_done` (type door) | `403 NOT_OVERRIDABLE` "'action' is not allowOrgOverride in the registry. Overlay-allowed: view, dashboard, report, translation, email_template. Set OS_METADATA_WRITABLE to enable additional types at runtime." | `403 NOT_OVERRIDABLE` "Metadata item 'action/showcase_mark_done' is provided by a code package, and its packaged base is locked against in-place edits. Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, :object = global for an object-less action; operator-only where one install serves several organizations). See docs/adr/0126-…" | | `PUT /api/v1/meta/action/showcase_mark_done?package=com.example.showcase` | `403 ITEM_LOCKED` "…Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=action …" | `403 ITEM_LOCKED` "Cannot overlay 'action' in package 'com.example.showcase': that package is read-only, and its packaged base is locked against in-place edits. Switch it off (…). See docs/adr/0126-…" | | `PUT /api/v1/meta/flow/showcase_task_completed` (package-less) | Regime C flow sentence | identical | | `PUT /api/v1/meta/permission/showcase_contributor`, with and without `?package=` | `plugin-security`'s authoring gate, already naming the clone | identical (that gate answers first on this boot) | | `DELETE` of the packaged flow, action and permission set | `200`, "No customization overlay found … already at artifact default" | identical | | control: `PUT /api/v1/meta/page/showcase_start_here`, with and without `?package=` | — | still the list-and-hatch sentence and the hatch-closed `ITEM_LOCKED` remedy "…or set OS_METADATA_WRITABLE=page …", which is unchanged | Of the nine readings, exactly the three rows above with a changed "after" column moved. **In process, on an environment kernel** (`env_1`), the real `/meta` route handler over a real `ObjectStackProtocolImplementation` and `SchemaRegistry`, with the package booted as code. These are the earlier rounds' readings, still pinned: - `PUT action` and `DELETE action` name the switch. - `PUT permission` names the clone. - `DELETE permission` is the `supportsOverlay` carve-out, not refused. - `?package=` on both kernel shapes answers `ITEM_LOCKED` with the row's path. **Docs drift** (`content/docs/concepts/metadata-lifecycle.mdx`, matched by `ObjectStackProtocolImplementation`). Two sentences contradicted the new text and are corrected, as listed under "What this changes". The page names no `OS_METADATA_WRITABLE` prescription for a flow, action or permission edit. The docs-drift-check comment (5923480340) also lists four release-owned pages. They were read and none of them states this refusal; they are release-owned and were not touched. ## Tests All at `9c169378a`. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 195 files passed and 3 skipped; 2920 tests passed and 19 skipped. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. The tsconfig includes `src/**/*`, tests included. - `pnpm --filter @objectstack/rest typecheck`: exit 0, with `check:test-typecheck` OK. - REST pins (`rest-meta-packaged-action-permission-refusal.test.ts` and `rest-meta-packaged-flow-refusal.test.ts`): 2 files, 15 tests passed. - New and moved pins: - `sys-metadata-repository.package-writability.test.ts`: - the type door on save, for each row, with the exact sentence; - the type door on removal, for `flow` and `action`; - the type-door control on `object`, byte for byte; - NARROW, where a hatch write of a packaged action still lands package-less; - the named base with the hatch closed, for each row, with the exact sentence and the full envelope; - the named base with the hatch open, on `flow`, byte for byte as before; - the 64-character package-id bound; - the moved #8146 pin on `page`, and its Regime C neighbour. - `protocol.packaged-base-refusal.test.ts`: the named-base limb through `packagedBaseRefusal` on both kernel shapes. The earlier per-row, literal-`flow` and 88-character pins stand. - `protocol.delete-rewrap-envelope.test.ts`: the control-plane removal prose is partitioned by regime row. `flow` and `action` now read their removal sentence, and every other type reads the type-door sentence as before. - `rest-meta-packaged-action-permission-refusal.test.ts`: a host-config kernel's `PUT action` and `PUT permission` hit the type door and name the path. `?package=` for both types on both kernel shapes answers `ITEM_LOCKED` with the path. - **Ablation**, run after committing `9c169378a`, through `scripts/ablation-replace.mjs`. It deleted every row from the table (anchor 1 → 0, blob `7f06cc1d9f9a` → `b876c9500402`), rebuilt `@objectstack/metadata-protocol`, and `ablation-dist-preflight --absent` reported the action-route marker absent from all 24 built files. - metadata-protocol pins: **24 failed** / 81 passed. Every regime pin went red, on all three doors, including flow's literal pin and both length bounds. These stayed green: the moved #8146 pin on `page`, the `object` type-door control, the hatch-open remedy, NARROW, the `page` controls and the delete-rewrap file (it partitions by the table, by design). - REST pins: **13 failed** / 2 passed. Only the permission `DELETE` carve-out and the `page` control stayed green. - Restore leg: blob equals HEAD and `git diff HEAD` is empty; the rebuild exited 0; the marker is present in 2 built files; the working tree is clean. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `9c169378a` (no paths, the change set from merge base `2f2fa11d7`, 9 paths) derives **92** commands. That is the earlier 62 plus the documentation families the `content/docs/concepts/` touch adds. All 92 were run there and **every one exited 0**. - Reconciled with `--ran` against a `command :: exit N` record: "92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero. - `origin/main` had not moved past the merge base when the gates were derived. - The artifact-roster, wide-population, path-scheduled-job and type-check-lane families are outside the derived list and are left to CI. - `pnpm lint` is CI's run and was not run here. ## Acceptance notes - The permission row's wording is a copy of `plugin-security`'s (`packages/plugins/plugin-security/src/packaged-permission-set-lock.ts`), because `metadata-protocol` cannot import that package. Nothing pins the two spellings equal across packages. Noted, not filed. Body refreshed for patch round 1 at 2026-10-01T03:16Z. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5dbeb7d commit 0c5a71b

9 files changed

Lines changed: 839 additions & 114 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): every refusal of an in-place edit of a packaged flow, action or permission set names that type's own sanctioned path, at every door, and a packaged action's removal names one too, not a redeploy or `OS_METADATA_WRITABLE` (#20910)
6+
7+
Clause-②: no
8+
9+
ADR-0126 puts `flow`, `action` and `permission` in Regime C. The packaged base is locked, and the refusal names the sanctioned path. Until now only a flow's package-less refusal did, and only at one of the three places that refuse such a write. The other places prescribed "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE …" or "Set OS_METADATA_WRITABLE to enable additional types at runtime". A packaged action's removal named no path at all.
10+
11+
There is now one regime table, and each type's row names only the primitives that type has:
12+
13+
- a packaged flow: clone it under a new name with `POST /api/v1/automation/:name/clone` and `{ name, label }`, or switch it off with `POST /api/v1/automation/:name/toggle` and `{ enabled: false }`;
14+
- a packaged action: switch it off with `POST /api/v1/actions/_activation/:object/:action` and `{ enabled: false }` (`:object` is `global` for an object-less action). No clone is named, because cloning an action is not a sanctioned path;
15+
- a packaged permission set: clone it under a new name, with the "Clone" action on the permission set or `POST /api/v1/data/sys_permission_set` with a new name. This is the same wording the permission-set lock in `@objectstack/plugin-security` already uses.
16+
17+
The switches are operator-only where one install serves several organizations. Every refusal cites ADR-0126. All three places that refuse such a write read the same table:
18+
19+
- **`403 NOT_OVERRIDABLE` on an environment-scoped kernel.** This covers `PUT /api/v1/meta/:type/:name` without `?package=`, and for a flow or an action `DELETE` too.
20+
- **`403 NOT_OVERRIDABLE` where the `/meta` protocol is not environment-scoped**, for example the default local `pnpm dev` boot. The metadata repository refuses that write one layer down, and now with the same sentence.
21+
- **`403 ITEM_LOCKED` for a write that names the read-only package with `?package=`, while `OS_METADATA_WRITABLE` is not set for the type.** The sentence now opens "Cannot overlay 'TYPE' in package 'ID': that package is read-only, and its packaged base is locked against in-place edits." and then names the path.
22+
23+
A packaged flow's package-less sentence is byte-for-byte unchanged. Statuses, codes, `lockSource`, `packageId`, `docs` and which writes are refused are unchanged too. `OS_METADATA_WRITABLE` still opens the lock for a write that names no package. The `ITEM_LOCKED` refusal given while the variable IS set reads exactly as before. Removing a permission set's overlay row is still allowed, as repair. Every type with no declared regime reads exactly as before, at every door.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ The `seq` is the single source of truth for ordering on the server. Note that th
9191
Every runtime-authored item lives inside a **writable package** — there are no
9292
orphans. Code-defined and installed packages are **read-only** at runtime, so the
9393
first step of any Studio/API authoring action is to target a *writable* package (a
94-
"base"): a create/update aimed at a read-only package is rejected, and the author
95-
is asked to pick or create a writable base first. New objects, fields, views, and
94+
"base"): a create aimed at a read-only package is rejected, and the author is asked
95+
to pick or create a writable base first. An in-place edit of an item the read-only
96+
package ships is refused too; for a flow, an action or a permission set the refusal
97+
names that type's sanctioned path instead (see [ADR-0126](#packaged-flows-actions-and-permission-sets-adr-0126)). New objects, fields, views, and
9698
flows are namespaced into that package — which is exactly what `os package publish`
9799
later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0070-package-first-authoring.md).
98100

@@ -109,7 +111,7 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust
109111
| `view`, `dashboard`, `report`, `email_template`, `translation` | ✅ | Pure rendering / render-time content. Per-org customization is safe. |
110112
| `flow` | ❌ | Flows carry execution side-effects (events, jobs, audit), and the `flow` entry declares `supportsOverlay: false` — the loader cannot merge a per-org flow overlay, so the write permission granted a write nothing could read back: an org-scoped flow overlay wrote successfully and **lost its binding on the next cold start**. Rolled back (#6283) so the silent phantom is a loud `403 not_overridable` at the moment of the write. **`allowRuntimeCreate` stays `true`** — a tenant may still author a *brand-new* flow through the runtime API (ADR-0070 package-first authoring); what is closed is overlaying a *packaged* flow per org. |
111113
| `agent` | ❌ | Agents are platform-owned and closed to third parties (ADR-0063 §2) — no per-org agent fork. |
112-
| `permission`, `position` | ❌ | Authorization correctness — a per-org overlay of a packaged permission set *is* silent privilege drift (ADR-0005's security row). Rolled back from an unratified `true` by the 2026-08-08 maintainer ruling (#6483); `position` takes the amendment's `false` default for new types. **`allowRuntimeCreate` stays `true`**: runtime-created sets (including package-bound rows materialized through the metadata door) keep working, while an admin-door edit of a *code-declared* set refuses with `403 not_overridable` — edit the package and re-publish (ADR-0086 two-doors). |
114+
| `permission`, `position` | ❌ | Authorization correctness — a per-org overlay of a packaged permission set *is* silent privilege drift (ADR-0005's security row). Rolled back from an unratified `true` by the 2026-08-08 maintainer ruling (#6483); `position` takes the amendment's `false` default for new types. **`allowRuntimeCreate` stays `true`**: runtime-created sets (including package-bound rows materialized through the metadata door) keep working, while an admin-door edit of a *code-declared* set refuses with `403 not_overridable`, naming the sanctioned path: clone the set under a new name and edit the clone ([ADR-0126](#packaged-flows-actions-and-permission-sets-adr-0126)). The package's own author still changes the declaration in the package and re-publishes (ADR-0086 two-doors). |
113115
| `object` | ❌ | Defines the table schema. Overriding a packaged object would break existing data — but `allowRuntimeCreate: true`, so tenants *can* author brand-new objects, **and this is also how a new field is added** (write the object with the field in `fields`). |
114116
| `field` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#7893). A field is not a standalone artifact — fields are authored inside their object (`ObjectSchema.fields`), so a `field` write minted a *separate* `sys_metadata` row keyed `('field','<object>.<name>')` that nothing ever composed into the parent: `PUT /meta/field/showcase_task.zz_probe` answered 200 `state=active`, the row read back `_diagnostics.valid: true`, and `GET /meta/object/showcase_task` never listed the field. The door is closed rather than bridged: add a field by writing its **object** (`PUT /meta/object/:name`, or `**/*.object.ts` and redeploy), which both persists and composes. Existing rows are untouched — they were inert before the change too — and remain deletable. |
115117
| `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (`allowRuntimeCreate: true` — the datasource wizard persists `origin: 'runtime'` rows.) |
@@ -126,6 +128,18 @@ The gate is **two-tier** — `allowOrgOverride: false` is not the same as "no ru
126128

127129
See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md) for the full design and amendments.
128130

131+
### Packaged flows, actions and permission sets (ADR-0126)
132+
133+
`flow`, `action` and `permission` are behavioral types, in Regime C of [ADR-0126](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0126-packaged-metadata-customization-model.md). A packaged item of one of these types is locked against in-place edits (and, for a flow or an action, against removal). The refusal names the type's sanctioned path, not a redeploy or `OS_METADATA_WRITABLE`, and each type names only the routes it has:
134+
135+
| Type | Sanctioned path the refusal names |
136+
| :--- | :--- |
137+
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). |
138+
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
139+
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |
140+
141+
The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged.
142+
129143
---
130144

131145
## Conflict handling
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20819, #20910, ADR-0126 §2 / §3] THE packaged-base regime table — the one
5+
* declaration of which metadata types' locked-base refusals speak for their
6+
* ADR-0126 customization regime, and of the sanctioned routes each such type
7+
* HAS. Every door that refuses an in-place write onto a packaged item of one of
8+
* these types builds its prescription from this table, and from nothing else:
9+
*
10+
* - the metadata protocol's package door (`protocol.ts`
11+
* `refusePackagedBaseOverride` / `refusePackagedBaseRemoval`), on an
12+
* environment-scoped kernel — {@link packagedBaseRegimeSentence};
13+
* - the repository's type door (`SysMetadataRepository.assertAllowed`), the
14+
* same refusal one layer down where the `/meta` protocol is not
15+
* environment-scoped — {@link packagedBaseRegimeSentence} too;
16+
* - the named-base `ITEM_LOCKED` limb (`SysMetadataRepository
17+
* .readOnlyBaseOverrideError`) with the hatch closed — {@link
18+
* packagedBaseRegimePrescription}.
19+
*
20+
* It lives in its own module because the two files that read it import each
21+
* other one way only (`protocol.ts` imports the repository): a table declared
22+
* in either would make the other reach back for it. Internal — not exported
23+
* from the package entry.
24+
*
25+
* ## Why it exists
26+
*
27+
* ADR-0126 §2 requires a Regime C refusal to name the sanctioned path — "in-place
28+
* edit refused loudly at the write door, the refusal naming the sanctioned path"
29+
* — and §10 gives the reason: a locked base that refuses with its sanctioned
30+
* path in the message is what keeps AI-written metadata from guessing. The
31+
* regime is recorded nowhere else in code (the ADR's table is prose, and the
32+
* registry entries carry no regime field), so this map is its one declaration
33+
* for the refusal — never a second list of type names, and never a type branch
34+
* in any door's prose.
35+
*
36+
* ## ONE regime, PER-TYPE routes
37+
*
38+
* A row is the type's regime plus the sanctioned routes that regime gives THAT
39+
* type: the regime decides the sentence's shape (locked; here is the sanctioned
40+
* path), the row supplies the paths, and the builders below compose the sentence
41+
* from the row alone. ADR-0126 §3's Regime C types declared here, each with only
42+
* the primitives it has, each route the one it is SERVED at:
43+
*
44+
* - `flow` — both (§7): the clone under a new name (§7.1,
45+
* `POST /automation/:name/clone`, body `{ name, label }`, both keys required)
46+
* and the enable/disable switch (§7.2, `POST /automation/:name/toggle`, body
47+
* `{ enabled }`);
48+
* - `action` — the switch only (§8 item 2, amendment ruling 3):
49+
* `POST /actions/_activation/:object/:action`, body `{ enabled }`, the
50+
* `:object` segment spelled `global` for an object-less action. ⛔ No clone:
51+
* the action-clone half is not chartered, so a clone route named here would
52+
* advertise one that does not exist;
53+
* - `permission` — the clone only (§8 item 3, the landed lock-and-clone
54+
* machinery): the "Clone" action on the permission set, which posts the copy
55+
* to `POST /data/sys_permission_set` under a new name. Spelled as
56+
* `plugin-security`'s own lock refusal spells that same path
57+
* (`packaged-permission-set-lock.ts`), so one condition reads one way at
58+
* every door.
59+
*
60+
* Both switch routes are gated by the ONE §5 write-authority gate
61+
* (`packages/runtime/src/domains/activation-gate.ts`), so both carry the same
62+
* operator-only clause. Still absent: ADR-0126 §3's pre-charted `tool` /
63+
* `skill` / `position` — no sanctioned path is built for them, and a row with no
64+
* route does not compile ({@link PackagedBaseRegimeCRoutes}).
65+
*
66+
* ⛔ No sentence built here names the `OS_METADATA_WRITABLE` hatch. The hatch
67+
* still opens these locks exactly as before, so which writes are refused does
68+
* not move — only what the refusal prescribes. ⛔ Nor does any prescribe editing
69+
* the source and redeploying: the administrator of an installed package cannot
70+
* do that.
71+
*/
72+
73+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
74+
75+
/** An ADR-0126 customization regime a packaged-base refusal speaks for — only the ones it needs today. */
76+
export type PackagedBaseRegime = 'C';
77+
78+
/**
79+
* The sanctioned routes a Regime C type's row supplies to its refusal: how to
80+
* reach that type's clone-as-sibling, and how to reach its enable/disable
81+
* switch — each spelled as the route a client calls (and its body), never as
82+
* prose about the type. A row declares the primitives its type HAS and only
83+
* those: the union refuses a row naming neither.
84+
*/
85+
export type PackagedBaseRegimeCRoutes =
86+
| { readonly clone: string; readonly switchOff?: string }
87+
| { readonly clone?: string; readonly switchOff: string };
88+
89+
/** One type's row: its regime, and the routes that regime names for it. */
90+
export interface PackagedBaseRegimeRow {
91+
readonly regime: PackagedBaseRegime;
92+
readonly routes: PackagedBaseRegimeCRoutes;
93+
}
94+
95+
/** The table. Keyed by the canonical (singular) metadata type. */
96+
export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow>> = {
97+
flow: {
98+
regime: 'C',
99+
routes: {
100+
clone: 'POST /api/v1/automation/:name/clone, body {name, label}',
101+
switchOff: 'POST /api/v1/automation/:name/toggle, body {enabled: false}; '
102+
+ 'operator-only where one install serves several organizations',
103+
},
104+
},
105+
action: {
106+
regime: 'C',
107+
routes: {
108+
switchOff: 'POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, '
109+
+ ':object = global for an object-less action; '
110+
+ 'operator-only where one install serves several organizations',
111+
},
112+
},
113+
permission: {
114+
regime: 'C',
115+
routes: {
116+
clone: 'the "Clone" action on the permission set, '
117+
+ 'or POST /api/v1/data/sys_permission_set with a new name',
118+
},
119+
},
120+
};
121+
122+
/** The type's row, read on the canonical type, or `undefined` when it declares no regime. */
123+
export function packagedBaseRegimeRow(type: string): PackagedBaseRegimeRow | undefined {
124+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
125+
return Object.prototype.hasOwnProperty.call(PACKAGED_BASE_REGIME, singular)
126+
? PACKAGED_BASE_REGIME[singular]
127+
: undefined;
128+
}
129+
130+
/**
131+
* The PRESCRIPTION half of a regime's refusal, built from the row: for Regime C
132+
* the row's sanctioned paths in one fixed order — clone first, then the switch —
133+
* and the citation of the ADR that decided them. Nothing in it reads the type:
134+
* a flow reads "Clone it …, or switch it off …" because its row has both, an
135+
* action reads only the switch and a permission set only the clone because
136+
* theirs have one.
137+
*/
138+
const PRESCRIPTION_BY_REGIME: Readonly<Record<PackagedBaseRegime, (routes: PackagedBaseRegimeCRoutes) => string>> = {
139+
C: (routes) => {
140+
// [verb opening the sentence, verb after "or", the rest of the path]
141+
const paths: Array<readonly [string, string, string]> = [
142+
...(routes.clone
143+
? [['Clone', 'clone', ` it under a new name to customize it (${routes.clone})`] as const]
144+
: []),
145+
...(routes.switchOff ? [['Switch', 'switch', ` it off (${routes.switchOff})`] as const] : []),
146+
];
147+
const prescription = paths
148+
.map(([opening, following, rest], i) => (i === 0 ? opening : following) + rest)
149+
.join(', or ');
150+
return `${prescription}. See docs/adr/0126-packaged-metadata-customization-model.md.`;
151+
},
152+
};
153+
154+
/**
155+
* The regime prescription for `type` — the row's sanctioned paths and the
156+
* ADR-0126 citation, one sentence pair, no opener — or `undefined` when the type
157+
* declares no regime and the emitter keeps its own remedy.
158+
*/
159+
export function packagedBaseRegimePrescription(type: string): string | undefined {
160+
const row = packagedBaseRegimeRow(type);
161+
return row ? PRESCRIPTION_BY_REGIME[row.regime](row.routes) : undefined;
162+
}
163+
164+
/**
165+
* The whole regime sentence for a packaged item `type/name` refused on save or
166+
* on removal — the lock, then {@link packagedBaseRegimePrescription} — or
167+
* `undefined` when the type declares no regime and the emitter keeps its own
168+
* sentence. Read on the canonical type, and spoken with it.
169+
*
170+
* Kept under the REST door's 500-character client-message bound
171+
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
172+
* the tail is truncated. Characters before the item's name, save / removal:
173+
* `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310 — so a name of up
174+
* to 88 characters arrives whole for every row (pinned). A `flow`'s sentence is
175+
* byte-identical to the one the row table replaced (pinned literally).
176+
*/
177+
export function packagedBaseRegimeSentence(
178+
type: string, name: string, operation: 'save' | 'delete',
179+
): string | undefined {
180+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
181+
const prescription = packagedBaseRegimePrescription(singular);
182+
if (prescription === undefined) return undefined;
183+
return `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked `
184+
+ (operation === 'delete' ? `against removal. ` : `against in-place edits. `)
185+
+ prescription;
186+
}

0 commit comments

Comments
 (0)