|
| 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