Skip to content

Commit 68c26aa

Browse files
committed
Merge commit '0c5a71b094' into claude/issue-21055-osv-next-dompurify
2 parents 3714ec8 + 0c5a71b commit 68c26aa

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)