Skip to content

Commit f5ea006

Browse files
committed
fix(runtime): a create onto a packaged flow's name keeps the packaged-base lock too
POST /automation onto a name the engine already holds is an overwrite; it now asks the same locked-base verdict as PUT /:name before anything is read or registered. Bounded in-place fix, adopted onto the claim's surface. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3690c44 commit f5ea006

3 files changed

Lines changed: 37 additions & 7 deletions

File tree

‎.changeset/20679-automation-door-package-lock.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
'@objectstack/metadata-protocol': minor
44
---
55

6-
fix(runtime): `PUT` and `DELETE /api/v1/automation/:name` refuse a packaged flow, as the metadata door does (#20679)
6+
fix(runtime): the `/automation` write doors refuse a packaged flow, as the metadata door does (#20679)
77

88
Clause-②: yes (widening)
99

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 remove it with `DELETE /api/v1/automation/:name`.
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`.
1111

12-
Both doors now answer `403` `NOT_OVERRIDABLE` for a packaged flow, with the same message the metadata door gives. 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.
12+
All three now answer `403` `NOT_OVERRIDABLE` for a packaged flow, with the same message the metadata door gives. 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.
1313

1414
What is not refused:
1515

‎packages/runtime/src/domains/automation-packaged-base-lock.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* [#20679, ADR-0126 §2] Write-door parity on a PACKAGED flow: `PUT` and
55
* `DELETE /automation/:name` refuse the same packaged artifact the metadata
66
* door refuses — the public checklist item
7-
* `access-security.packaged-flow-write-door-parity`, clauses 2 and 3.
7+
* `access-security.packaged-flow-write-door-parity`, clauses 2 and 3 — and so
8+
* does `POST /automation` onto its name, which would otherwise overwrite it.
89
*
910
* ## What this pins, at door level
1011
*
@@ -217,6 +218,26 @@ describe('a PACKAGED flow — the base is locked at both /automation definition
217218
});
218219
});
219220

221+
it('POST / onto a packaged flow\'s name — a create that would overwrite — is refused as a locked base; a new name is created', async () => {
222+
const h = boot();
223+
const before = h.held(PACKAGED);
224+
225+
const overwrite = await h.dispatcher.handleAutomation(
226+
'', 'POST', definitionOf(PACKAGED, 'Overwritten via create'), AUTHOR(), undefined,
227+
);
228+
expect(statusOf(overwrite.response)).toBe(403);
229+
expect(errorOf(overwrite.response).code).toBe('NOT_OVERRIDABLE');
230+
expect(h.registerFlow).not.toHaveBeenCalled();
231+
expect(h.held(PACKAGED)).toBe(before);
232+
233+
// Control: a name no code package ships is created exactly as before.
234+
const created = await h.dispatcher.handleAutomation(
235+
'', 'POST', definitionOf('brand_new_flow', 'New'), AUTHOR(), undefined,
236+
);
237+
expect(statusOf(created.response)).toBe(200);
238+
expect(h.registerFlow).toHaveBeenCalledWith('brand_new_flow', expect.objectContaining({ label: 'New' }));
239+
});
240+
220241
it('keys on the NAME the artifact loader registered — provenance stamps in the request body decide nothing', async () => {
221242
// The verdict is asked with `{ type, name, operation }` only: whether a
222243
// code package ships the name is read from the registry's loader-made

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,17 +1448,18 @@ function flowDefinitionRefusal(err: any): unknown {
14481448

14491449
/**
14501450
* [#20679, ADR-0126 §2] THE LOCKED BASE at this domain's definition-write and
1451-
* removal doors — `PUT /:name` and `DELETE /:name`.
1451+
* removal doors — `PUT /:name`, `DELETE /:name`, and `POST /` onto a name the
1452+
* engine already holds (a create onto an existing name is an overwrite).
14521453
*
14531454
* ADR-0126 §2 puts a packaged flow in Regime C: "the packaged base is locked —
14541455
* in-place edit refused loudly at the write door". The metadata door kept that
14551456
* promise (`PUT /meta/flow/:name` on a flow a code package ships answers `403`
1456-
* `NOT_OVERRIDABLE`) and these two doors did not: behind the `manage_metadata`
1457+
* `NOT_OVERRIDABLE`) and these doors did not: behind the `manage_metadata`
14571458
* authoring gate they went straight to the engine's `registerFlow` /
14581459
* `unregisterFlow`, which hold no lock and must not grow one — the boot pull
14591460
* registers every packaged flow through that same method. So an administrator
14601461
* refused at one door onto the artifact could rewrite, or remove, the same
1461-
* packaged flow in the live engine through the other.
1462+
* packaged flow in the live engine through another.
14621463
*
14631464
* ## One predicate, asked of its owner
14641465
*
@@ -1959,6 +1960,7 @@ export async function classifyResumeResult(
19591960
* GET /:name → getFlow
19601961
* POST / → createFlow (registerFlow)
19611962
* ⚑ authoring write — `manage_metadata` (#10145)
1963+
* ⚑ packaged base locked — as `PUT /:name`
19621964
* PUT /:name → updateFlow
19631965
* ⚑ authoring write — `manage_metadata` (#10145)
19641966
* ⚑ packaged base locked — the `/meta` door's
@@ -2240,6 +2242,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
22402242
// `edge.condition` strings lowered to their envelopes) — the same
22412243
// shape `GET /automation/:name` serves — never an echo of the
22422244
// caller's own pre-parse bytes.
2245+
// [#20679, ADR-0126 §2] Creating onto a name the engine already
2246+
// holds is an overwrite (below), so a packaged flow's name is
2247+
// locked here exactly as on `PUT /:name` — the same verdict, asked
2248+
// before anything is read or registered. A name no code package
2249+
// ships is created as before.
2250+
const locked = await refusePackagedFlowBaseChange(deps, context, body.name, 'save');
2251+
if (locked) return locked;
22432252
// [#20552] Creating onto a name the engine already holds is an
22442253
// overwrite, so the round-trip rule applies here as on `PUT /:name`.
22452254
const definition = await keepStoredFlowCredentials(automationService, body.name, body);

0 commit comments

Comments
 (0)