Skip to content

Commit 4b45afa

Browse files
fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) (#20817)
Fixes #20679 Clause-②: yes (widening) The `/automation` definition doors now refuse a packaged flow, with the same locked-base verdict the metadata door gives. This is the public checklist item `access-security.packaged-flow-write-door-parity`: clauses 2 and 3 flip to PASS, and clauses 1 and 4 still pass, measured on a booted showcase. The reproduction stays withheld as filed. This body stays at the door level the public item already describes. ## What changed **`packages/runtime/src/domains/automation.ts`** - `PUT /:name` and `DELETE /:name` call `refusePackagedFlowBaseChange` before the engine is called, and relay its refusal. - Order at each door: `manage_metadata` authoring gate, then the body envelope check (PUT and POST only), then the lock, then the engine. - A refused write reads and registers nothing. A refused removal unregisters nothing. - **Bounded in-place fix, adopted onto the claim's surface by the seat:** `POST /` onto a name the engine already holds overwrites that flow. It now takes the same lock, right after the name check. - Evidence at `3690c44b`, before the fix, from a throwaway probe (real protocol over a real `SchemaRegistry`, deleted afterwards): a create onto a packaged flow's name answered `200`, `registerFlow` was entered once, and the packaged flow's label was overwritten. - Pinned now: `403 NOT_OVERRIDABLE`, `registerFlow` never entered, and a create under a new name still succeeds. - The helper resolves the `protocol` slot with `resolveServiceOrLoud`, unscoped, exactly as the `/meta` domain resolves it. So both doors ask the same protocol instance. - If the slot is wired but fails to resolve, the error is re-raised: the write fails and does not proceed as unlocked. - A composition with no metadata protocol keeps today's behaviour. It has no `/meta` door to be at parity with. **`packages/metadata-protocol/src/protocol.ts`**: the widening, a cross-lane surface the seat adopted. - New public method `ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation })`. It returns the refusal the `/meta` door gives for writing (`'save'`) or removing (`'delete'`) an existing item that a code package ships, or `null` when that door would not refuse on this ground. - The `/meta` verdict (`isArtifactBacked` + `isOverlayAllowed`, and its emitters) was private to this class, so a second door could not ask it any other way. - The verdict is lifted, not copied. Two private helpers, `refusePackagedBaseOverride` and `refusePackagedBaseRemoval`, carry `saveMetaItem`'s and `deleteMetaItem`'s inline package-door code verbatim (proof below). - Both methods call the helpers where the inline code stood. - The helpers still throw, as that code did. - `packagedBaseRefusal` is the one place a throw becomes a value. It re-raises anything that is not a `403` `NOT_OVERRIDABLE` / `ITEM_LOCKED`. - **Why runtime relays the refusal instead of stamping its own code.** `NOT_OVERRIDABLE` and `ITEM_LOCKED` are ledgered under `@objectstack/metadata-protocol` (ADR-0112). `@objectstack/runtime`'s owner key lists neither. - A runtime stamp would need a ledger row or a waiver in `packages/spec`. - It would also be a second emitter for one condition. - Relayed through `errorFromThrown`, the code, status and sentence are the producer's. - `check:error-code-provenance`: 330 stamp sites, 313 listed, 17 waived, OK. **What the lock keys on.** The flow's NAME, looked up in the registry's artifact-only lookup (`SchemaRegistry.getArtifactItem`, `packages/objectql/src/registry.ts:3919`). That lookup scans the PACKAGE_ID:NAME entries the artifact loader registers. - The door hands the verdict `{ type, name, operation }` and nothing else. - Neither the request body nor the engine's registered flow is consulted. - Pinned both ways: a packaged name is refused whatever provenance stamps the body carries, and a customer flow is not locked by a body that claims a package. **Two refusals on DELETE.** The lock (`403 NOT_OVERRIDABLE`) answers before the engine's ADR-0126 §7.3 refusal (`DELETE_RESTRICTED` / `409`, a packaged subflow that packaged callers still reach). - Every packaged flow is locked first, so at this door the §7.3 refusal is reached only where the lock admits the removal: with `OS_METADATA_WRITABLE=flow`. - The engine's guard is unchanged. - ⛔ No lock was added to `registerFlow` / `unregisterFlow`: the boot pull registers packaged flows through them. **What stays open.** - A flow no code package ships: created, updated and removed as before. - `POST /:name/clone`, the ADR-0126 §7.1 customization path. - `POST /:name/toggle`, the activation switch. Its packaged-only rule from #20726 is untouched. - The `OS_METADATA_WRITABLE=flow` operator hatch, which the refusal sentence names. It opens this door exactly as it opens `/meta`: same `isOverlayAllowed`. ## Lift proof: each lifted helper body, before and after, whitespace-insensitive ```bash git show f284ab2:packages/metadata-protocol/src/protocol.ts | sed -n '16014,16093p' | tee old-save.txt | wc -l # 80: saveMetaItem's inline package door git show f5ea006:packages/metadata-protocol/src/protocol.ts | sed -n '14300,14379p' | tee new-save.txt | wc -l # 80: refusePackagedBaseOverride body diff -w old-save.txt new-save.txt | wc -l # 0 git show f284ab2:packages/metadata-protocol/src/protocol.ts | sed -n '21920,21931p' | tee old-delete.txt | wc -l # 12: deleteMetaItem's inline refusal git show f5ea006:packages/metadata-protocol/src/protocol.ts | sed -n '14401,14412p' | tee new-delete.txt | wc -l # 12: refusePackagedBaseRemoval body diff -w old-delete.txt new-delete.txt | wc -l # 0 ``` | helper | lines before | lines after | `diff -w` changed lines | |:--|--:|--:|--:| | `refusePackagedBaseOverride` | 80 | 80 | 0 | | `refusePackagedBaseRemoval` | 12 | 12 | 0 | The only added lines compute the locals the inline code read from its enclosing method: - `overlayAllowed` in the override helper; - `overlayAllowed` and `artifactBacked` in the removal helper. Each is spelled exactly as in the calling method. `deleteMetaItem` keeps its own copies for its `NOT_CREATABLE` check. ## Pins - `packages/runtime/src/domains/automation-packaged-base-lock.test.ts` (15 cases). Real `ObjectStackProtocolImplementation` over a real `SchemaRegistry`, with the packaged flow registered the way the loader registers it; the automation service is a spy. - PUT and DELETE refused on a host-config kernel and on an environment kernel. - The door's answer equals `saveMetaItem`'s / `deleteMetaItem`'s thrown refusal: code, status and sentence. - The POST create-overwrite. - Body stamps decide nothing. - The envelope check keeps its place. - Controls: customer flow, runtime-row and tenant-bound registry items, clone, toggle, the hatch, no protocol, a failing protocol (not fail-open). - `packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts` (9 cases): - equality with the two methods' throws; - the type folded at the producer; - `null` for a name no package ships and for a Regime O type; - the #6960 delete carve-out; - the hatch; - a failing lookup re-raised, never handed out as a verdict. - `packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts`: the derived fold population gains `packagedBaseRefusal` ("all fourteen"). - `packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts` (5 cases), the real showcase composition over HTTP: - Clause 1 control: `PUT /meta/flow/:name` answers `403`, code `NOT_OVERRIDABLE`, in the REST door's envelope. - Clauses 2 and 3: `PUT` / `DELETE /automation/:name` answer `403 NOT_OVERRIDABLE`, and the definition reads back byte-identical. - Clause 4: no residue. The enabled/bound row is unchanged. - A customer flow is created, updated and removed through the same door. ## Tests (measured) At `3690c44b` (merge over #20726): - `@objectstack/runtime` `local` project: 292 files, 4215 passed, 1 skipped. - `@objectstack/runtime` `repo` project: 727 passed. - `@objectstack/metadata-protocol`: 191 files passed (3 skipped); 2801 passed, 19 skipped. - `@objectstack/metadata-protocol` typecheck: exit 0. - `@objectstack/objectql`, the 20 files pinning `NOT_OVERRIDABLE` / `NOT_CREATABLE` / `ITEM_LOCKED` on `saveMetaItem` / `deleteMetaItem`: 362 passed. - `@objectstack/rest`, 3 files: 41 passed. At `f5ea0060` (head): - Every `automation-*.test.ts` in `@objectstack/runtime`: 25 files, 478 passed. - `@objectstack/runtime` typecheck: exit 0, `check:test-typecheck` OK. - The two protocol pin files: 27 passed. - Dogfood pin, after a runtime rebuild: 5 passed. ## Reverse verification Each leg was committed first, then mutated through `scripts/ablation-replace.mjs` (anchor hit 1, blob changed). Dist legs were proven by `ablation-dist-preflight` (marker in 2 built files). Every restore was proven (blob == HEAD, `git diff HEAD` empty, tree clean, marker absent from dist). Predicted and measured agree on every leg. | leg | head | suite | predicted | measured | |:--|:--|:--|:--|:--| | door helper disabled (runtime src) | `3690c44b` | runtime pin | 7 red / 7 green | 7 red / 7 green | | same, runtime rebuilt | `3690c44b` | dogfood pin | 3 red / 2 green | 3 red / 2 green | | `packagedBaseRefusal` returns `null` (rebuilt) | `3690c44b` | protocol pin | 6 red / 3 green | 6 red / 3 green | | same | `3690c44b` | runtime pin | 6 red / 8 green | 6 red / 8 green | | re-raise discriminator widened | `3690c44b` | protocol pin | 1 red / 8 green | 1 red / 8 green | | POST call removed | `f5ea0060` | runtime pin | 1 red / 14 green | 1 red / 14 green | After every restore, all pins were green again. ## Gates - `dispatch-gates --commands` at `f5ea0060`: 67 derived. `--ran` reconciliation: 67 run, 0 NOT-MEASURED, 0 unrun, every exit 0. - Also run: `check:error-code-casing` and `@objectstack/spec` `check:error-code-provenance`, both exit 0. - `pnpm lint`, narrowed and proven: 1. The population comes from `eslint.config.mjs` (the `**/*.{ts,…}` and `packages/**/*.{ts,…}` blocks). All 6 changed `.ts` files are in it. 2. `--format json` counted 6 files linted, 0 errors, 0 warnings, at `f5ea0060`. 3. The config never enables type-aware linting (`eslint.config.mjs` states it: no `parserOptions.project`, no typed rules), and its four plugins are local AST rules. So this diff cannot move any untouched file's verdict. ## Acceptance notes (observed, not filed) - **The ADR-0126 §2 refusal wording.** §2 says the refusal names the sanctioned path. For a packaged flow, the shared `NOT_OVERRIDABLE` sentence names "edit the source artifact and redeploy" and the `OS_METADATA_WRITABLE` hatch. It does not name clone (§7.1). This holds on both doors, because the sentence is one emitter. It is reported to the seat, not changed here. - **Two envelopes for one refusal.** On this composition `/meta` answers through the REST server's refusal envelope, `{ error, code }` with a flat string `error`. `/automation` answers through the dispatcher's, `{ success, error: { code, message } }`. Pre-existing. The dogfood pin reads each where it lives. - **`@objectstack/rest` inlines the protocol.** It declares `@objectstack/metadata-protocol` as a devDependency, so its built `dist/` carries its own copy of the protocol class. Pre-existing. It is rebuilt with the same source. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73155fe commit 4b45afa

7 files changed

Lines changed: 1012 additions & 93 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(runtime): the `/automation` write doors refuse a packaged flow, as the metadata door does (#20679)
7+
8+
Clause-②: yes (widening)
9+
10+
A flow that a code package ships has a locked base (ADR-0126 §2): changing or removing it in place is refused. `PUT /api/v1/meta/flow/:name` already refused it. The two `/automation` definition doors did not: an administrator holding `manage_metadata` could rewrite a packaged flow in the live engine with `PUT /api/v1/automation/:name` or with `POST /api/v1/automation` under its name (a create onto an existing name overwrites it), or remove it with `DELETE /api/v1/automation/:name`.
11+
12+
All three now answer a packaged flow with the same code and status the metadata door gives (`403` `NOT_OVERRIDABLE`), and with the same sentence wherever the metadata protocol's own package door answers. The refusal comes before the engine is called, so nothing is registered or removed. On `DELETE`, it also comes before the engine's own `DELETE_RESTRICTED` / `409` for a packaged subflow that packaged callers still reach.
13+
14+
What is not refused:
15+
16+
- A flow that no code package ships, including a flow created with `POST /api/v1/automation` or authored through the metadata door. It is updated and removed as before.
17+
- `POST /api/v1/automation/:name/clone`, which copies a packaged flow under a new name. This is the supported way to customize one (ADR-0126 §7.1).
18+
- `POST /api/v1/automation/:name/toggle`, the switch that turns a packaged flow on or off (ADR-0126 §7.2).
19+
- A deployment that sets `OS_METADATA_WRITABLE=flow`. It opens both doors, as the refusal message says.
20+
21+
**The widening.** `@objectstack/metadata-protocol` gains one public method, `ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation })`. It returns the refusal the metadata door would give for writing (`'save'`) or removing (`'delete'`) an existing item because a code package ships it, or `null` when that door would not refuse on this ground. `saveMetaItem` and `deleteMetaItem` call the same code, so the two doors cannot disagree. Their own refusals are unchanged.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20679, ADR-0126 §2] `packagedBaseRefusal` — the `/meta` door's
5+
* locked-base verdict, as a value, for a second door onto the same artifact.
6+
*
7+
* It is not a new rule. It hands out the SAME verdict `saveMetaItem` and
8+
* `deleteMetaItem` reach — the package doors both now call, lifted out of them
9+
* unchanged, `refusePackagedBaseOverride` / `refusePackagedBaseRemoval` — so this file
10+
* pins two things and only two:
11+
*
12+
* 1. it answers exactly what those two methods throw for the same item (the
13+
* ONE-emitter claim: same code, status and sentence);
14+
* 2. its matrix is the metadata door's matrix, including the answers that are
15+
* deliberately `null` — a name no package ships, a Regime O overlay type,
16+
* the #6960 delete carve-out, and the operator hatch.
17+
*
18+
* The registry double serves only `getArtifactItem`, which is all the verdict
19+
* reads; what it returns is what the real `SchemaRegistry` returns for an
20+
* artifact a code package registered (`_packageId` stamped, package
21+
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
22+
* this package.
23+
*/
24+
import { afterEach, describe, expect, it } from 'vitest';
25+
import { ObjectStackProtocolImplementation } from './protocol.js';
26+
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
27+
28+
const PACKAGE_ID = 'com.example.pkg';
29+
30+
const shipped = (name: string, extra: Record<string, unknown> = {}) =>
31+
({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package', ...extra });
32+
33+
/** Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`). */
34+
const ARTIFACTS = new Map<string, Map<string, unknown>>([
35+
['flow', new Map([['pkg_flow', shipped('pkg_flow', { type: 'autolaunched', nodes: [], edges: [] })]])],
36+
['view', new Map([['pkg_view', shipped('pkg_view')]])],
37+
['page', new Map([['pkg_page', shipped('pkg_page')]])],
38+
]);
39+
40+
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
41+
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
42+
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
43+
}
44+
45+
const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);
46+
47+
afterEach(() => {
48+
delete process.env.OS_METADATA_WRITABLE;
49+
ObjectStackProtocolImplementation.resetEnvWritableCache();
50+
resetEnvWritableMetadataTypes();
51+
});
52+
53+
describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed to a second door', () => {
54+
for (const environmentId of [undefined, 'env_1']) {
55+
it(`refuses a packaged flow's save AND removal on ${environmentId ? 'an environment' : 'a host-config'} kernel`, () => {
56+
const p = protocolOn(environmentId);
57+
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })))
58+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
59+
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })))
60+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
61+
});
62+
}
63+
64+
it('ONE emitter: the value equals what saveMetaItem / deleteMetaItem throw for the same item', async () => {
65+
const p = protocolOn('env_1');
66+
const thrownBy = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);
67+
68+
const save = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } }));
69+
const saveVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' });
70+
expect({ ...shape(save), message: save?.message }).toEqual({ ...shape(saveVerdict), message: saveVerdict?.message });
71+
72+
const del = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' }));
73+
const delVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' });
74+
expect({ ...shape(del), message: del?.message }).toEqual({ ...shape(delVerdict), message: delVerdict?.message });
75+
});
76+
77+
it('folds the type at the producer — a plural spelling cannot address around the lock', () => {
78+
const p = protocolOn(undefined);
79+
expect(shape(p.packagedBaseRefusal({ type: 'flows', name: 'pkg_flow', operation: 'save' })))
80+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
81+
});
82+
83+
it('a name no code package ships is not locked — creation is not this verdict\'s question', () => {
84+
const p = protocolOn(undefined);
85+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'save' })).toBeNull();
86+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'delete' })).toBeNull();
87+
});
88+
89+
it('a Regime O overlay type (allowOrgOverride) is never refused on this ground', () => {
90+
const p = protocolOn(undefined);
91+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
92+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'delete' })).toBeNull();
93+
});
94+
95+
it('mirrors the #6960 carve-out: a rolled-back overlay type refuses the write but not the removal', () => {
96+
const p = protocolOn(undefined);
97+
expect(shape(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })))
98+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
99+
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
100+
});
101+
102+
it('a lookup that FAILS is re-raised, never handed out as a verdict', () => {
103+
// The lifted helpers throw, as the inline code did; only the refusal is
104+
// turned into a value. A registry that cannot answer must not become a
105+
// well-formed "refused" (or "allowed") — it stays the fault it is.
106+
const registry = { getArtifactItem: () => { throw new Error('registry unreadable'); } };
107+
const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), undefined);
108+
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }))
109+
.toThrow('registry unreadable');
110+
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }))
111+
.toThrow('registry unreadable');
112+
});
113+
114+
it('reads the operator hatch through the same predicate the metadata door reads', () => {
115+
process.env.OS_METADATA_WRITABLE = 'flow';
116+
ObjectStackProtocolImplementation.resetEnvWritableCache();
117+
const p = protocolOn(undefined);
118+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })).toBeNull();
119+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull();
120+
});
121+
});

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all thirteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all fourteen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -280,6 +280,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
280280
// in-process caller hands it a type spelling too.
281281
'getMetaItemsForExecution',
282282
'historyMetaItem',
283+
// [#20679] The locked-base verdict a second write door asks — it
284+
// takes the type a caller names, so it folds at the producer too.
285+
'packagedBaseRefusal',
283286
'publishMetaItem',
284287
'rollbackMetaItem',
285288
'saveMetaItem',

0 commit comments

Comments
 (0)