Skip to content

Commit cb4c31d

Browse files
fix(runtime)!: the /automation create and update doors save the flow as a tenant row, so what they answer 200 for survives a restart (#20862) (#20907)
Fixes #20862 Clause-②: no (narrowing) ## What this does `POST /api/v1/automation` and `PUT /api/v1/automation/NAME` registered a flow in the automation engine and wrote no metadata row. The next boot binds flows from the stored metadata, so a created flow was gone after a restart, and an update to a flow stored through `/meta` lost to the stored definition. Both doors now save the flow as a tenant row through the metadata protocol's own `saveMetaItem`, env-wide, which is the path PR #20853 gave the clone door. This follows the triage direction on the card (`5912757695`) and the maintainer's rule 2 recorded on #20761 (`5904938166`): every flow written through an authoring door goes through one shared function. - **One path for three doors.** `registerAndSaveFlow` in `packages/runtime/src/domains/automation.ts` registers the definition in the engine, then saves the same definition through `saveMetaItem({ type: 'flow', name, item })`. It passes no organization, no package and no mode, so the row is live (`active`), like the engine registration. The create door, the update door and the clone door all call it. There is no second persistence path, and `saveMetaItem` is called, not changed. - **Engine first, store second.** The engine's registration is still the first check. Its refusal is answered as before (`400 VALIDATION_FAILED`), and nothing is saved. - **A failed save leaves no registration of its own.** The store's refusal is relayed with its own code and status. A new name is withdrawn from the engine. A name the engine already held gets back the definition it held, so a refused update does not take the flow down. - **The refusals in front stay in front.** The #20679 locked-base refusal on a packaged flow's name (`403 NOT_OVERRIDABLE`) and #20853's `tenantAuthoredWriteRefusal` (`422 INVALID_METADATA`) still answer before the engine or the store is touched. - **No store, no save.** A composition with no metadata protocol keeps the engine-only registration it always had, as the clone door already did. The runtime pin states this case. - **Draft or live:** the save uses `saveMetaItem`'s default mode, which is `publish`, so the row is `active`. That matches the clone door. A draft row would leave the engine running a flow the next boot does not bind. ### The removal door keeps pace (beyond the card's two arms, declared) Once the create door saves, `DELETE /automation/NAME`, which only unregistered the flow in the engine, would leave the row behind, and the next boot would bind the flow again. This was measured with the create and update half alone on `1921e7e3e2`: a flow created then deleted through the door still read `200` on `GET /meta/flow/NAME`, and `200` on `GET /automation/NAME` after a cold boot. So `unregisterAndDeleteFlow` removes the flow from the engine, then deletes the row through the protocol's own `deleteMetaItem`, env-wide. - The engine's removal refusal (`DELETE_RESTRICTED` / `409`) is still raised before the store is touched. - A name with no row is removed as before. - A delete the store refuses puts the definition back in the engine and relays the refusal. ## Repro, before this change Measured with the booted pin `packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts` on base `2d5fe76f43` plus the pin commit `d09cee8a9c`, through `bootStack` on the showcase with a database file: - `POST /automation` answered `200`. `GET /meta/flow/NAME` answered `404 RESOURCE_NOT_FOUND`. After a cold boot on the same file, `GET /automation/NAME` answered `404`. - `PUT /automation/NAME` on a flow stored through `/meta` answered `200`. `/meta` still served `Stored through /meta`. After the cold boot, `GET /automation/NAME` served `Stored through /meta`, not the update. ## Declared narrowing `Clause-②: no (narrowing)`. The changeset ships `minor` with a BREAKING banner and an ADR-0087 disposition (`not-required (no-migration-prescription)`). Nothing widens. The doors now refuse what the metadata store refuses. Two bodies measured on the showcase answered `200` before and are refused now: - **A flow name with a leading underscore.** `FlowSchema.name` admits it and the metadata item-name grammar does not. The door now answers `400 INVALID_REQUEST`. - **A definition the runtime publish gate refuses.** The measured case is a default edge that carries a condition (`flow-default-edge-with-condition`). The door now answers `422 INVALID_METADATA`. Neither flow could ever be stored, so before this change it ran only until the next restart. The claim's reading was `no`. The measured diff moves the doors' accept set for these bodies, so the declaration names the arm. ## Pins - `packages/runtime/src/domains/automation-authoring-doors-durable.test.ts` (new, 11 cases). It uses a real protocol over a real `SchemaRegistry`, with the store's save and delete spied. It pins: - the save arguments, and that the engine is called first; - a failed create is withdrawn; - a failed update puts back the definition the engine held; - an engine refusal saves nothing; - the locked-base and provenance refusals reach neither the engine nor the store; - the removal door's delete, its undo, and the §7.3 refusal coming first; - a composition with no protocol. - `packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts` (new, 7 cases, cold boot on one database file): - a created flow survives; - an update to a `/meta`-stored flow survives; - a create the store refuses answers `400 INVALID_REQUEST` and `GET` answers `404`; - an update the store refuses answers `422 INVALID_METADATA` and the engine keeps the previous definition; - a flow created then deleted stays deleted; - control: a packaged name is still refused `403 NOT_OVERRIDABLE`, on `PUT` and on a create onto it. - `packages/runtime/src/domains/automation-packaged-base-lock.test.ts`: the harness gains `standInStore()`. Four success-path cases now reach the real save, and this harness's engine has no store (measured: 4 cases at `500`, `this.engine.find is not a function`). They stand the store in, the way the file's own clone case already did. No assertion changed. ## Verification, at head `43cf17d79b` - `pnpm --filter @objectstack/runtime test` (local project): 296 files, 4245 passed, 1 skipped, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck` (tsc plus the test layer) and `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Dogfood: the new pin plus `flow-provenance-server-held`, `packaged-flow-write-door-parity`, `automation-flow-clone-door` and `automation-toggle-tenant-scope`: 5 files, 38 tests, exit 0. The runtime `dist/` was rebuilt at this head first. - `node scripts/pm/dispatch-gates.mjs --commands`: all 65 derived commands were run, each exit recorded, and `--ran` answered `65 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm lint` (`eslint . --no-inline-config`, whole tree): exit 0. - Also run: `pnpm check:durability-log-level` and `pnpm check:adr-anchors`, exit 0 (on `a783880517`). - **Reverse verification.** These were one-shot ablations through `scripts/ablation-replace.mjs` in wrap mode, against the committed implementation. The runtime pin imports source, so there is no `dist` leg. Each restore was proven: blob equal to HEAD `2df430b334b6`, and `git diff HEAD` empty. - Save call removed: 4 of 11 red. - Delete call removed: 2 of 11 red. - Held-definition restore disabled: 1 of 11 red. - The booted pin's before-leg is the repro above: 5 of 7 red on the unfixed tree. ## Acceptance notes (observations, not filed) - **A tenant-package-bound row.** The update door saves env-wide with no package, as the triage direction says. On a flow stored through `/meta` with `?package=`, the update therefore lands as a package-less row beside the bound one. A bare `PUT /meta/flow/NAME` already does the same today. Which row a later boot binds was NOT MEASURED. Taker: none. - **Item-name grammar and `FlowSchema.name` disagree** on a leading underscore. The refusal is loud and prescriptive at every runtime door, and a code-shipped flow is loaded, not saved, so this is not a silent trap. Taker: none. - **Drafts.** `DELETE` removes the live row only, as `/meta`'s delete without a state does. A pending draft row stays. Taker: none. - **Version history.** Putting back a held definition re-registers it, which adds one entry to the engine's in-memory version history. No runtime door serves that history. Taker: none. - **Stale tree.** `origin/main` moved after the merge `a783880517`. The only gate-family input that changed is `scripts/doc-authoring-prose-id.baseline.json`, which this diff does not touch. CI measures the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f1f1c1 commit cb4c31d

5 files changed

Lines changed: 674 additions & 59 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the /automation create and update doors save the flow as a tenant row, so what they answer 200 for survives a restart, and the removal door deletes that row too (#20862)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which definition writes and removals the /automation doors accept: the ones the metadata store refuses are now refused there too. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `POST /api/v1/automation` and `PUT /api/v1/automation/:name` now refuse a definition the metadata store refuses, which they used to register and answer `200` for. `DELETE /api/v1/automation/:name` now relays a store's refusal to delete the flow's row.
12+
13+
**What changed.** The create and update doors registered a flow in the automation engine and wrote no metadata row. The next boot binds flows from the stored metadata, so a flow created through `POST /automation` was gone after a restart, and an update through `PUT /automation/:name` to a flow stored through `/meta` lost to the stored definition. Both doors now save the definition through the metadata protocol's own `saveMetaItem`: the save `PUT /api/v1/meta/flow/:name` uses, and the one the clone door (`POST /automation/:name/clone`) already used. The row is env-wide and live (`active`), so the flow reads back on `/meta` and survives a cold boot. The three doors share one path.
14+
15+
- **Engine first, store second.** The engine's registration is still the first check. Its refusal is answered as before (`400 VALIDATION_FAILED`), and nothing is saved.
16+
- **A save the store refuses is relayed with its own code and status, and leaves no registration behind.** A create is withdrawn from the engine. An update puts back the definition the engine held, so a refused update does not take the flow down.
17+
- **`DELETE /automation/:name` deletes the tenant row too**, through `deleteMetaItem`, so a flow created through the door does not come back at the next boot. The engine's own removal refusal (`DELETE_RESTRICTED` / `409`) is still raised before the store is touched. A name with no stored row is removed as before. A delete the store refuses puts the definition back and relays the refusal.
18+
- **Unchanged:** the locked-base refusal on a packaged flow's name (`403 NOT_OVERRIDABLE`) and the refusal of a definition claiming a package's provenance (`422 INVALID_METADATA`) still answer first. A composition with no metadata protocol keeps the engine-only registration and removal it always had.
19+
20+
**Newly refused, because the metadata store refuses them** (measured on the showcase):
21+
22+
- A flow name with a leading underscore. `FlowSchema` admits it and the metadata item-name grammar does not, so the door answers `400 INVALID_REQUEST`. Rename the flow to a name that starts with a letter.
23+
- A definition a gating runtime publish rule refuses, such as a default edge that also carries a condition (`flow-default-edge-with-condition`). The door answers `422 INVALID_METADATA` with the rule's finding. Fix the definition as the finding says.
24+
25+
Such a flow could never be stored, so before this change it ran only until the next restart.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20862, ADR-0126 §2, ADR-0131 D6] The `/automation` definition doors keep
4+
// what they answer `200` for — over the real showcase composition, across a
5+
// cold boot on one database file.
6+
//
7+
// ## What was broken
8+
//
9+
// `POST /automation` and `PUT /automation/:name` registered the definition in
10+
// the engine and wrote no metadata row. The next boot binds flows from the
11+
// stored metadata, so a created flow was gone after a restart, and an update
12+
// to a flow stored through `/meta` was overwritten by the stored definition.
13+
// Measured on `origin/main` before the fix, through this file's own cases:
14+
// the created flow answered `404` after the cold boot, and the updated flow
15+
// served its stored label again.
16+
//
17+
// ## Why a booted stack
18+
//
19+
// The door-level pins sit beside the domain
20+
// (`packages/runtime/src/domains/automation-authoring-doors-durable.test.ts`).
21+
// What they cannot answer is whether, on the stack an operator runs, the door
22+
// reaches a protocol whose save lands a row the next boot binds. Only a
23+
// restart on the same file shows that.
24+
//
25+
// ## What each case pins
26+
//
27+
// - a created flow reads back on the metadata door and survives a cold boot;
28+
// - an update to a `/meta`-stored flow reads back there and survives it;
29+
// - a save the store refuses leaves no engine registration: a create is
30+
// withdrawn, and an update puts back the definition the engine held;
31+
// - a flow created and then removed through the door stays removed after the
32+
// cold boot (the removal door keeps pace with the create door);
33+
// - a packaged flow's name is still refused as a locked base (the control).
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import showcaseStack from '@objectstack/example-showcase';
37+
import { bootStack, type VerifyStack } from '@objectstack/verify';
38+
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
39+
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
40+
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
41+
import { fileURLToPath } from 'node:url';
42+
import { mkdtempSync, rmSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { join } from 'node:path';
45+
46+
/** Package-relative connector refs resolve against the cwd — see the sibling boots. */
47+
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
48+
49+
/** A flow the showcase package ships (`com.example.showcase`). */
50+
const SHIPPED = 'showcase_urgent_task_alert';
51+
/** Names no package ships, each used by one case. */
52+
const CREATED = 'dogfood_created_flow_20862';
53+
const META_STORED = 'dogfood_meta_stored_flow_20862';
54+
const REMOVED = 'dogfood_removed_flow_20862';
55+
/**
56+
* A name the engine registers and the store refuses: `FlowSchema.name` admits a
57+
* leading underscore, the metadata item-name grammar does not. So the engine's
58+
* registration succeeds and the save fails — a real store refusal, reached
59+
* through the public door.
60+
*/
61+
const UNSAVABLE = '_dogfood_unsavable_flow_20862';
62+
63+
const UPDATED_LABEL = 'Updated through the automation door';
64+
65+
const definition = (name: string, label = `Dogfood ${name}`) => ({
66+
name,
67+
label,
68+
type: 'autolaunched',
69+
nodes: [
70+
{ id: 'start', type: 'start', label: 'Start', config: {} },
71+
{ id: 'end', type: 'end', label: 'End' },
72+
],
73+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
74+
});
75+
76+
/**
77+
* A definition the engine registers and the store's publish gate refuses: a
78+
* default edge carrying a condition (`flow-default-edge-with-condition`, a
79+
* gating authoring rule the engine does not run).
80+
*/
81+
const gateRefused = (name: string) => ({
82+
...definition(name, 'Refused by the publish gate'),
83+
edges: [{ id: 'e1', source: 'start', target: 'end', isDefault: true, condition: 'true' }],
84+
});
85+
86+
interface DispatcherEnvelope {
87+
success?: boolean;
88+
data?: Record<string, unknown>;
89+
error?: { code?: string; message?: string };
90+
}
91+
const dataOf = (json: unknown) => (json as DispatcherEnvelope).data;
92+
const dispatcherCode = (json: unknown) => (json as DispatcherEnvelope).error?.code;
93+
94+
const plugins = () => [
95+
new ConnectorRestPlugin(),
96+
new ConnectorOpenApiPlugin(),
97+
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
98+
];
99+
100+
async function boot(databaseFile: string): Promise<VerifyStack> {
101+
return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() });
102+
}
103+
104+
describe('the /automation definition doors persist what they register (showcase, cold boot)', () => {
105+
let stack: VerifyStack;
106+
let token: string;
107+
let prevCwd: string;
108+
let dir: string;
109+
let dbFile: string;
110+
111+
const call = async (method: string, path: string, body?: unknown) => {
112+
const res = await stack.apiAs(token, method, path, body);
113+
const json: unknown = await res.json().catch(() => ({}));
114+
return { status: res.status, json };
115+
};
116+
/** `GET /meta/flow/:name` — the served document, unwrapped from whichever envelope carries it. */
117+
const metaRead = async (name: string) => {
118+
const read = await call('GET', `/meta/flow/${name}`);
119+
const json = read.json as Record<string, unknown>;
120+
const doc = (json?.data ?? json?.item ?? json) as Record<string, unknown>;
121+
return { status: read.status, doc: (doc?.item ?? doc) as Record<string, unknown> };
122+
};
123+
const engineLabel = async (name: string) => {
124+
const read = await call('GET', `/automation/${name}`);
125+
return { status: read.status, label: dataOf(read.json)?.label };
126+
};
127+
128+
beforeAll(async () => {
129+
prevCwd = process.cwd();
130+
process.chdir(SHOWCASE_DIR);
131+
dir = mkdtempSync(join(tmpdir(), 'dogfood-20862-'));
132+
dbFile = join(dir, 'showcase.db');
133+
stack = await boot(dbFile);
134+
token = await stack.signIn();
135+
}, 180_000);
136+
137+
afterAll(async () => {
138+
await stack?.stop();
139+
if (prevCwd) process.chdir(prevCwd);
140+
if (dir) rmSync(dir, { recursive: true, force: true });
141+
});
142+
143+
it('a flow created through the automation door reads back on the metadata door', async () => {
144+
const created = await call('POST', '/automation', definition(CREATED));
145+
expect(created.status, JSON.stringify(created.json)).toBe(200);
146+
147+
const served = await metaRead(CREATED);
148+
expect(served.status, JSON.stringify(served.doc)).toBe(200);
149+
expect(served.doc.label).toBe(`Dogfood ${CREATED}`);
150+
});
151+
152+
it('an update through the automation door to a /meta-stored flow reads back on the metadata door', async () => {
153+
const stored = await call('PUT', `/meta/flow/${META_STORED}`, definition(META_STORED, 'Stored through /meta'));
154+
expect(stored.status, JSON.stringify(stored.json)).toBe(200);
155+
156+
const updated = await call('PUT', `/automation/${META_STORED}`, definition(META_STORED, UPDATED_LABEL));
157+
expect(updated.status, JSON.stringify(updated.json)).toBe(200);
158+
159+
expect((await metaRead(META_STORED)).doc.label).toBe(UPDATED_LABEL);
160+
expect((await engineLabel(META_STORED)).label).toBe(UPDATED_LABEL);
161+
});
162+
163+
it('a create whose save the store refuses answers the store\'s refusal and leaves no engine registration', async () => {
164+
const created = await call('POST', '/automation', definition(UNSAVABLE));
165+
166+
expect(created.status, JSON.stringify(created.json)).toBe(400);
167+
expect(dispatcherCode(created.json)).toBe('INVALID_REQUEST');
168+
expect((await call('GET', `/automation/${UNSAVABLE}`)).status).toBe(404);
169+
});
170+
171+
it('an update whose save the store refuses answers the refusal and the engine keeps the definition it held', async () => {
172+
const updated = await call('PUT', `/automation/${META_STORED}`, gateRefused(META_STORED));
173+
174+
expect(updated.status, JSON.stringify(updated.json)).toBe(422);
175+
expect(dispatcherCode(updated.json)).toBe('INVALID_METADATA');
176+
expect(await engineLabel(META_STORED)).toEqual({ status: 200, label: UPDATED_LABEL });
177+
expect((await metaRead(META_STORED)).doc.label).toBe(UPDATED_LABEL);
178+
});
179+
180+
it('a flow created and then removed through the automation door is gone from the metadata door too', async () => {
181+
const created = await call('POST', '/automation', definition(REMOVED));
182+
expect(created.status, JSON.stringify(created.json)).toBe(200);
183+
184+
const removed = await call('DELETE', `/automation/${REMOVED}`);
185+
expect(removed.status, JSON.stringify(removed.json)).toBe(200);
186+
187+
expect((await call('GET', `/automation/${REMOVED}`)).status).toBe(404);
188+
expect((await metaRead(REMOVED)).status).toBe(404);
189+
});
190+
191+
it('control: a packaged flow\'s name is still refused as a locked base, on the update door and on a create onto it', async () => {
192+
const before = dataOf((await call('GET', `/automation/${SHIPPED}`)).json)!;
193+
194+
const put = await call('PUT', `/automation/${SHIPPED}`, { ...before, label: 'Edited in place' });
195+
expect(put.status, JSON.stringify(put.json)).toBe(403);
196+
expect(dispatcherCode(put.json)).toBe('NOT_OVERRIDABLE');
197+
198+
const post = await call('POST', '/automation', { ...before, label: 'Overwritten via create' });
199+
expect(post.status, JSON.stringify(post.json)).toBe(403);
200+
expect(dispatcherCode(post.json)).toBe('NOT_OVERRIDABLE');
201+
202+
expect(dataOf((await call('GET', `/automation/${SHIPPED}`)).json)).toEqual(before);
203+
});
204+
205+
it('after a cold boot on the same database file, every door\'s outcome is what it answered', async () => {
206+
await stack.stop();
207+
stack = await boot(dbFile);
208+
token = await stack.signIn();
209+
210+
// Independent facts about one restart, so each is reported on its own.
211+
// The created flow survives.
212+
expect.soft(await engineLabel(CREATED)).toEqual({ status: 200, label: `Dogfood ${CREATED}` });
213+
// The update survives: the stored definition no longer wins over it.
214+
expect.soft(await engineLabel(META_STORED)).toEqual({ status: 200, label: UPDATED_LABEL });
215+
// The refused create left nothing to bind, and the removed flow stays removed.
216+
expect.soft((await call('GET', `/automation/${UNSAVABLE}`)).status).toBe(404);
217+
expect.soft((await call('GET', `/automation/${REMOVED}`)).status).toBe(404);
218+
// The packaged flow is the one its package ships.
219+
expect.soft((await engineLabel(SHIPPED)).status).toBe(200);
220+
}, 180_000);
221+
});

0 commit comments

Comments
 (0)