Skip to content

Commit 9ca36b1

Browse files
fix(runtime): the automation run doors answer another flow's run as an unknown one (#25944)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQva2z8vP7oShKLska8DrgP)_ Fixes #25820 Clause-②: no Metadata-protocol: none Contract review owed (packages/spec/src/** diff): one ledger row, `RUN_NOT_FOUND` under `@objectstack/runtime` in `packages/spec/src/api/error-code-ledger.zod.ts`, per surface amendment 6407846633. ## What changed Every `/automation/:name/runs/:runId/*` door read the run by `runId` alone, so the `:name` segment was accepted and ignored. `packages/runtime/src/domains/automation.ts` now checks that the path's flow owns the run (`runIsOutsideFlow`). A flow that exists but does not own the run, and an unknown flow name, both get the door's own answer for a run nobody holds: | Door | Read by id alone on main? | Mismatch answer now | |---|---|---| | `POST /:name/runs/:runId/resume` | yes, resumed the other flow's run | `404 RUN_NOT_FOUND`, `No suspended run 'RUNID'` (the engine's unknown-run answer, through the same `classifyResumeResult` table) | | `GET /:name/runs/:runId` | yes, served it | `404 RUN_NOT_FOUND`, `Execution not found` | | `GET /:name/runs/:runId/screen` | yes, served the screen | `404 No pending screen for run` (unchanged not-found answer) | | `POST /:name/runs/:runId/cancel` | yes, cancelled it | `200 { cancelled: false }` with the not-found notice, the verb's contract answer for no run | | `POST /:name/runs/:runId/restore-suspension` | yes, re-armed it | `404`, `refusal: RUN_NOT_FOUND`, `No run 'RUNID' is known` (the engine's unknown-run answer) | | `GET /:name/runs` | no, already reads by flow (`listRunsPage(name)`) | not touched | | MCP `resume_run` | takes no flow name | not applicable | - **Order.** The check runs after each door's existing gates (`refuseUnrelatedResume`, the #7900 read gate, the #13953 operator gate) and immediately before the verb, so a mismatched resume, cancel or restore consumes nothing and the run stays as it was. Authorization is unchanged. On the screen door it sits beside the existing not-found check, ahead of the gate, so the 404 is the same for every caller. - **Owner.** `ExecutionLog.flowName` from `getRun`, else `listSuspendedRuns`, which still names a parked run when `getRun` answers `null`. A service with neither probe, or a run neither can see, keeps the previous answer: the door cannot prove a mismatch. - **Run-read code.** The run-read door's not-found answer now carries `RUN_NOT_FOUND` for an unknown run too (it was the status-derived `RESOURCE_NOT_FOUND`), so "another flow's run" and "no such run" are one answer on that door, as on resume. - **Ledger.** The door now stamps `RUN_NOT_FOUND` itself before the engine is asked, so `check:error-code-provenance` needs the code under `@objectstack/runtime`; it was listed under `@objectstack/service-automation` only. ## Tests - New `packages/runtime/src/domains/automation-run-flow-scope.test.ts`, for each of the five doors: the mismatched answer deep-equals the unknown-flow answer; it equals the door's answer for a run nobody holds (runId masked); no state-changing verb is called; the owning flow is still served; the owner is still found through the suspended-run list when `getRun` is blind. Plus the ruling's code and status on resume and run-read, and the no-probe pass-through. - Red first on main (test commit `3f5e709d` against unmodified `automation.ts`): 13 failed, 12 passed. The 12 that passed are the comparisons main already satisfies by ignoring the name for both sides. - Two existing pins assumed the door reads `getRun` once and were adjusted, with comments: `automation-resume-caller-gate.test.ts` (a system context: the gate still asks nothing, the one `getRun` is the scope check) and `automation-screen-read-gate.test.ts` (a throwing store now throws on every read, not only the first). Measured at head `e88a8ecb3` (the final commit): - `pnpm --filter @objectstack/runtime test` at this head: 445/445 files, 7125 passed, 19 skipped (lock VERDICT command-exit 0). The first full run, before the two pin adjustments, was 7123 passed and 2 failed (those two pins). - `pnpm --filter @objectstack/runtime typecheck`: exit 0. `pnpm --filter @objectstack/spec test`: 720 files, 21840 tests pass. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `eslint --no-inline-config` on the five touched source and test files: exit 0. - Derived gates (`dispatch-gates.mjs --commands`): 93 run, every exit 0, `--ran` reconciliation 93 derived, 0 NOT-MEASURED, 0 UNRUN. `check-plugin-teardown-shape --self-test` and `check:dual-build-cjs-loads` first answered exit 3 (shallow clone, no full build); rerun after fetching the fixture commit and a full build, both exit 0. Includes `check:error-code-provenance` (exit 0 with the new ledger row) and `check:error-code-casing`. - Ablation (one, on the predicate): `return owner !== undefined && owner !== flowName;` replaced with `return false;` via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed): the new suite went 11 failed, 14 passed. Restored: `git diff HEAD` empty and the file's hash equals the HEAD blob. The suite imports the dispatcher from source, so no rebuild was needed for either leg. ## Acceptance notes - The resume and restore mismatch messages repeat the engine's sentences (`run-resume.ts`, `consumed-suspension-restore.ts`) in the runtime. The runtime tests pin them against a fake that answers in the same spelling, not against the real engine, so an engine rewording would make the two answers differ by message. Carrier: none. - `content/docs/automation/flows.mdx` (the route table) does not say the `:name` segment is checked. Docs follow the implementation; not edited here (outside the claimed surface). - Sibling #25821 (same file) is not addressed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GZJEQmPZSkLhXy1w9yVMM2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a04ba2 commit 9ca36b1

6 files changed

Lines changed: 350 additions & 9 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime): the automation run doors answer another flow's run as an unknown one
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** Every `/automation/:name/runs/:runId/*` door read the run by `runId` alone, so the `:name` segment was accepted and ignored. `POST /automation/OTHER_FLOW/runs/RUNID/resume` completed another flow's paused run, `GET /automation/OTHER_FLOW/runs/RUNID` and `…/screen` served it, and `…/cancel` and `…/restore-suspension` acted on it.
12+
- **Now.** A flow name that does not own the run, whether it exists or not, is answered exactly as the door answers a run nobody holds:
13+
- `POST …/resume`: `404 RUN_NOT_FOUND`, `No suspended run 'RUNID'`, the engine's own unknown-run answer.
14+
- `GET …/runs/:runId`: `404 RUN_NOT_FOUND`, `Execution not found`. An unknown run now carries the same code (it was the status-derived `RESOURCE_NOT_FOUND`), so the two doors onto one run agree it is not there.
15+
- `GET …/screen`: `404 No pending screen for run`, unchanged.
16+
- `POST …/cancel`: `200 { cancelled: false }` with the not-found notice.
17+
- `POST …/restore-suspension`: `404`, `refusal: 'RUN_NOT_FOUND'`, `No run 'RUNID' is known`.
18+
- **Nothing is consumed.** The check runs after the doors' existing gates and before the verb, so a mismatched resume, cancel or restore leaves the run as it was. Who may act is unchanged.
19+
- **Owner.** The run's `flowName` from `getRun`, else the suspended-run list. A service that can name neither keeps the previous answer.
20+
- **Ledger.** `RUN_NOT_FOUND` is now registered under `@objectstack/runtime` too, since the door stamps it before the engine is asked.

‎packages/runtime/src/domains/automation-resume-caller-gate.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,10 @@ describe('#19987 — the resume door admits the run\'s starter, or the run-state
328328

329329
expect(statusOf(response)).toBe(200);
330330
expect(h.resume).toHaveBeenCalledTimes(1);
331-
expect(h.getRun).not.toHaveBeenCalled();
331+
// The gate reads nothing for a system context. The one `getRun` is
332+
// the #25820 scope check (does the path's flow own the run?), which
333+
// holds for every caller and runs after this gate.
334+
expect(h.getRun).toHaveBeenCalledTimes(1);
332335
expect(h.explainCalls).toHaveLength(0);
333336
});
334337
});
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25820] The `:name` segment of every `/automation/:name/runs/:runId/*` door
5+
* is a SCOPE the door checks, not a decoration it ignores.
6+
*
7+
* Every one of these doors reads the run by `runId` alone, so before this card
8+
* `POST /automation/OTHER_FLOW/runs/RUNID/resume` completed another flow's
9+
* paused run, and `GET /automation/OTHER_FLOW/runs/RUNID` served it. The URL
10+
* claimed a scope the server never checked: a caller holding a stale or
11+
* mixed-up runId acted on the wrong flow's run, silently, and the audit trail
12+
* read as if the named flow had been acted on.
13+
*
14+
* The ruling (triage 6399818476): a flow name that exists but does not own the
15+
* run answers what an UNKNOWN name answers — `404 RUN_NOT_FOUND` on the resume
16+
* and run-read doors — byte for byte. Each door below is held to the same
17+
* sentence: "exists but not the owner" ≡ "unknown flow" ≡ "unknown run", where
18+
* the last is that door's own not-found answer for a runId nobody holds. The
19+
* fake engine answers an unknown run in the real engine's spelling
20+
* (`service-automation` `run-resume.ts` / `consumed-suspension-restore.ts`).
21+
*
22+
* ⛔ Not an authorization change. Who may act is still decided where it was
23+
* (`refuseUnrelatedResume`, the #7900 read gate, the #13953 operator gate); the
24+
* scope check runs after those, immediately before the verb, so a mismatched
25+
* request consumes nothing: the run stays paused and nothing is written.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
30+
import { HttpDispatcher } from '../http-dispatcher.js';
31+
import type { HttpProtocolContext } from '../http-dispatcher.js';
32+
33+
const OWNER = 'showcase_reassign_wizard';
34+
const OTHER = 'showcase_project_closure';
35+
const UNKNOWN = 'no_such_flow';
36+
const RUN = 'run_wizard_1';
37+
const NOBODYS_RUN = 'run_nobody_holds';
38+
39+
interface HarnessOptions {
40+
/** `getRun` answers `null` for every run, as after a degraded durable read. */
41+
getRunBlind?: boolean;
42+
/** Neither `getRun` nor `listSuspendedRuns` is mounted. */
43+
noOwnerProbe?: boolean;
44+
}
45+
46+
function makeHarness(options: HarnessOptions = {}) {
47+
const runs = new Map([[RUN, {
48+
id: RUN,
49+
flowName: OWNER,
50+
status: 'paused',
51+
trigger: { type: 'api', userId: 'usr_admin' },
52+
startedAt: '2026-10-26T00:00:00.000Z',
53+
}]]);
54+
const flows = new Set([OWNER, OTHER]);
55+
const screen = { title: 'Reassign', fields: [{ name: 'new_assignee', type: 'text' }] };
56+
57+
const resume = vi.fn(async (runId: string) => (runs.has(runId)
58+
? { success: true, runId, output: { done: true } }
59+
: { success: false, code: 'RUN_NOT_FOUND', error: `No suspended run '${runId}'` }));
60+
const cancelRun = vi.fn(async (runId: string) => runs.has(runId));
61+
const restoreConsumedSuspension = vi.fn(async (runId: string) => (runs.has(runId)
62+
? { restored: true, runId, reason: 'Suspension restored.' }
63+
: { restored: false, runId, refusal: 'RUN_NOT_FOUND', reason: `No run '${runId}' is known` }));
64+
const getSuspendedScreen = vi.fn(async (runId: string) => (runs.has(runId) ? screen : null));
65+
66+
const automation: Record<string, unknown> = {
67+
handlerReady: true,
68+
execute: vi.fn(),
69+
resume,
70+
cancelRun,
71+
restoreConsumedSuspension,
72+
getSuspendedScreen,
73+
listFlows: async () => [...flows],
74+
getFlow: async (name: string) => (flows.has(name) ? { name, nodes: [] } : null),
75+
};
76+
if (!options.noOwnerProbe) {
77+
automation.getRun = async (runId: string) => (options.getRunBlind ? null : runs.get(runId) ?? null);
78+
automation.listSuspendedRuns = () => [...runs.values()].map((r) => ({ runId: r.id, flowName: r.flowName, nodeId: 'collect' }));
79+
}
80+
81+
const services: Record<string, unknown> = { automation };
82+
const resolve = (name: string): unknown => services[name];
83+
const kernel = {
84+
getService: resolve,
85+
getServiceAsync: async (name: string) => resolve(name),
86+
context: { getService: resolve, trigger: async () => undefined },
87+
};
88+
return {
89+
dispatcher: new HttpDispatcher(kernel as never),
90+
resume,
91+
cancelRun,
92+
restoreConsumedSuspension,
93+
};
94+
}
95+
96+
/** Clears every gate the doors keep (#7900 read, #19987 resume, #13953 operator). */
97+
const ADMIN_CTX = (): HttpProtocolContext =>
98+
({ request: {}, executionContext: { userId: 'usr_admin', posture: 'PLATFORM_ADMIN' } } as HttpProtocolContext);
99+
100+
interface Door {
101+
label: string;
102+
method: 'GET' | 'POST';
103+
path: (flow: string, runId: string) => string;
104+
body?: unknown;
105+
/** The verb that changes state; asserted never called on a mismatch. */
106+
verb?: 'resume' | 'cancelRun' | 'restoreConsumedSuspension';
107+
/** The door's own success status for the owning flow. */
108+
okStatus: number;
109+
}
110+
111+
const DOORS: Door[] = [
112+
{
113+
label: 'POST /:name/runs/:runId/resume',
114+
method: 'POST',
115+
path: (f, r) => `${f}/runs/${r}/resume`,
116+
body: { inputs: { new_assignee: 'usr_b' } },
117+
verb: 'resume',
118+
okStatus: 200,
119+
},
120+
{ label: 'GET /:name/runs/:runId', method: 'GET', path: (f, r) => `${f}/runs/${r}`, okStatus: 200 },
121+
{ label: 'GET /:name/runs/:runId/screen', method: 'GET', path: (f, r) => `${f}/runs/${r}/screen`, okStatus: 200 },
122+
{
123+
label: 'POST /:name/runs/:runId/cancel',
124+
method: 'POST',
125+
path: (f, r) => `${f}/runs/${r}/cancel`,
126+
verb: 'cancelRun',
127+
okStatus: 200,
128+
},
129+
{
130+
label: 'POST /:name/runs/:runId/restore-suspension',
131+
method: 'POST',
132+
path: (f, r) => `${f}/runs/${r}/restore-suspension`,
133+
verb: 'restoreConsumedSuspension',
134+
okStatus: 200,
135+
},
136+
];
137+
138+
async function send(h: ReturnType<typeof makeHarness>, door: Door, flow: string, runId: string) {
139+
const { response } = await h.dispatcher.handleAutomation(door.path(flow, runId), door.method, door.body, ADMIN_CTX(), {});
140+
return { status: (response as any)?.status, body: (response as any)?.body };
141+
}
142+
143+
describe('#25820 — a flow name that does not own the run is answered as an unknown one', () => {
144+
for (const door of DOORS) {
145+
describe(door.label, () => {
146+
it('a flow that exists but does not own the run answers exactly what an unknown flow name answers', async () => {
147+
const h = makeHarness();
148+
const mismatched = await send(h, door, OTHER, RUN);
149+
const unknownFlow = await send(h, door, UNKNOWN, RUN);
150+
expect(mismatched).toEqual(unknownFlow);
151+
});
152+
153+
it('…which is exactly the door\'s answer for a run nobody holds — no existence oracle', async () => {
154+
const h = makeHarness();
155+
const mismatched = await send(h, door, OTHER, RUN);
156+
const nobodys = await send(h, door, OWNER, NOBODYS_RUN);
157+
// The runId is echoed in some answers; compare with it named.
158+
expect(JSON.stringify(mismatched).split(RUN).join('RUNID'))
159+
.toBe(JSON.stringify(nobodys).split(NOBODYS_RUN).join('RUNID'));
160+
});
161+
162+
if (door.verb) {
163+
it('a mismatched request reaches no state-changing verb', async () => {
164+
const h = makeHarness();
165+
await send(h, door, OTHER, RUN);
166+
await send(h, door, UNKNOWN, RUN);
167+
expect(h[door.verb!]).not.toHaveBeenCalled();
168+
});
169+
}
170+
171+
it('control: the owning flow name is served as before', async () => {
172+
const h = makeHarness();
173+
const owned = await send(h, door, OWNER, RUN);
174+
expect(owned.status).toBe(door.okStatus);
175+
if (door.verb) {
176+
expect(h[door.verb]).toHaveBeenCalledTimes(1);
177+
expect(h[door.verb].mock.calls[0]?.[0]).toBe(RUN);
178+
}
179+
});
180+
181+
it('the owner is still checked when `getRun` cannot see the run, through the suspended-run list', async () => {
182+
const h = makeHarness({ getRunBlind: true });
183+
const mismatched = await send(h, door, OTHER, RUN);
184+
const unknownFlow = await send(h, door, UNKNOWN, RUN);
185+
expect(mismatched).toEqual(unknownFlow);
186+
if (door.verb) expect(h[door.verb]).not.toHaveBeenCalled();
187+
});
188+
});
189+
}
190+
191+
it('resume and run-read answer the mismatch 404 RUN_NOT_FOUND (the ruling, by code and status)', async () => {
192+
const h = makeHarness();
193+
const resumed = await send(h, DOORS[0], OTHER, RUN);
194+
expect(resumed.status).toBe(404);
195+
expect(resumed.body?.error?.code).toBe('RUN_NOT_FOUND');
196+
const read = await send(h, DOORS[1], OTHER, RUN);
197+
expect(read.status).toBe(404);
198+
expect(read.body?.error?.code).toBe('RUN_NOT_FOUND');
199+
});
200+
201+
it('a service that cannot name a run\'s flow keeps today\'s answer: the door cannot prove a mismatch', async () => {
202+
const h = makeHarness({ noOwnerProbe: true });
203+
const resumed = await send(h, DOORS[0], OTHER, RUN);
204+
expect(resumed.status).toBe(200);
205+
expect(h.resume).toHaveBeenCalledTimes(1);
206+
});
207+
});

‎packages/runtime/src/domains/automation-screen-read-gate.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ describe('#7968 — the paused-run screen is gated to its trigger identity, or t
384384

385385
it('treats a getRun THROW as unresolved identity, not as a match', async () => {
386386
const h = makeDispatcher('refusing');
387-
h.getRun.mockRejectedValueOnce(new Error('run store unreachable'));
387+
// Every read throws, not just the first: since #25820 the door's
388+
// scope check reads the run once ahead of this gate, and a store
389+
// that is unreachable is unreachable for both.
390+
h.getRun.mockRejectedValue(new Error('run store unreachable'));
388391
const { response } = await h.dispatcher.handleAutomation(
389392
SCREEN_PATH, 'GET', undefined, TRIGGERING_USER(), undefined,
390393
);

0 commit comments

Comments
 (0)