Skip to content

Commit 7307191

Browse files
claude[bot]claude
andauthored
fix(automation): hold a signal-less resume to the screen-input contract (#14388)
`AutomationEngine.refuseInvalidScreenInput` opened with a bare `if (!signal) return null;`, so `resume(runId)` with no signal object skipped the `required` screen-field check that `resume(runId, {})` is held to. The engine already names its one legitimate exemption through `ENGINE_BUILT_SIGNAL`; the early return was a second, unnamed spelling. The public `resume` door now normalises an absent signal to `{}` (the shape the HTTP route has always assembled for an empty body), and `resumeInternal` / `refuseInvalidScreenInput` / `applyResumeSignal` take a non-optional signal, so a falsy-signal branch cannot grow back. Pauses that declare no input contract are unaffected. Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4485f7d commit 7307191

3 files changed

Lines changed: 296 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A signal-less `resume(runId)` is held to the suspended screen's declared
5+
* field contract exactly like a signal-carrying one (#13648).
6+
*
7+
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
8+
* null;` — so `resume(runId, { variables: {} })` was refused with
9+
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
10+
* unconditional `required` field unbound. The engine already had a NAMED
11+
* exemption for the one legitimate case — its own continuations, tagged
12+
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
13+
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
14+
* the governed side wins — the early return is gone, an absent signal is an
15+
* empty submission, and the engine-built flag is the only exemption left.
16+
*
17+
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
18+
* assembles `{}` for an empty body — so these pins sit on the in-process door
19+
* `AutomationEngine.resume`, which is also what the wait node's timer wake
20+
* calls with no signal (and must keep doing: a `wait` pause declares no
21+
* screen contract, so an empty submission against it is conformant;
22+
* `wait-node.test.ts` owns that half).
23+
*/
24+
25+
import { describe, it, expect, beforeEach } from 'vitest';
26+
import { AutomationEngine } from '../engine.js';
27+
import type { NodeExecutor } from '../engine.js';
28+
import { installBuiltinNodes } from './index.js';
29+
30+
function silentLogger() {
31+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
32+
}
33+
function ctx() {
34+
return { logger: silentLogger(), getService() { return undefined; } } as any;
35+
}
36+
37+
/** A one-screen flow whose screen declares exactly `fields`. */
38+
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
39+
return {
40+
name,
41+
label: name,
42+
type: 'screen',
43+
status: 'active',
44+
version: 1,
45+
nodes: [
46+
{ id: 'start', type: 'start', label: 'Start' },
47+
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
48+
{ id: 'end', type: 'end', label: 'End' },
49+
],
50+
edges: [
51+
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
52+
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
53+
],
54+
};
55+
}
56+
57+
const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];
58+
59+
describe('signal-less resume of a screen with a required field (#13648)', () => {
60+
let engine: AutomationEngine;
61+
62+
beforeEach(() => {
63+
engine = new AutomationEngine(silentLogger());
64+
installBuiltinNodes(engine, ctx());
65+
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
66+
});
67+
68+
async function pause(): Promise<string> {
69+
const started = await engine.execute('triage', {} as any);
70+
expect(started.status).toBe('paused');
71+
expect(started.screen?.nodeId).toBe('ask');
72+
return started.runId!;
73+
}
74+
75+
it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
76+
const runId = await pause();
77+
78+
const res = await engine.resume(runId);
79+
80+
// The ADR-0112 envelope, not a bare "it failed": the same code and the
81+
// same first sentence the signal-carrying refusal answers.
82+
expect(res.success).toBe(false);
83+
expect(res.code).toBe('INVALID_SCREEN_INPUT');
84+
expect(res.error).toMatch(/^Invalid screen input: /);
85+
expect(res.error).toContain('"kind"');
86+
expect(res.error).toMatch(/required/i);
87+
// The pause was NOT consumed — the run is exactly where it was.
88+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
89+
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
90+
});
91+
92+
it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
93+
const runId = await pause();
94+
const bare = await engine.resume(runId);
95+
const empty = await engine.resume(runId, { variables: {} });
96+
expect(bare).toEqual(empty);
97+
});
98+
99+
it('resumes the same run once the field is supplied', async () => {
100+
const runId = await pause();
101+
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');
102+
103+
const good = await engine.resume(runId, { variables: { kind: 'normal' } });
104+
105+
expect(good.success).toBe(true);
106+
expect(good.code).toBeUndefined();
107+
expect(good.status).toBeUndefined(); // ran to completion
108+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
109+
});
110+
});
111+
112+
describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
113+
let engine: AutomationEngine;
114+
115+
beforeEach(() => {
116+
engine = new AutomationEngine(silentLogger());
117+
installBuiltinNodes(engine, ctx());
118+
});
119+
120+
async function pauseOn(flow: Record<string, unknown>): Promise<string> {
121+
engine.registerFlow(flow.name as string, flow as any);
122+
const started = await engine.execute(flow.name as string, {} as any);
123+
expect(started.status).toBe('paused');
124+
return started.runId!;
125+
}
126+
127+
it('a screen whose fields are all optional', async () => {
128+
const runId = await pauseOn(screenFlow('optional_only', [
129+
{ name: 'note', label: 'Note', type: 'text' },
130+
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
131+
]));
132+
const res = await engine.resume(runId);
133+
expect(res.success).toBe(true);
134+
expect(res.code).toBeUndefined();
135+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
136+
});
137+
138+
it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
139+
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
140+
const res = await engine.resume(runId);
141+
expect(res.success).toBe(true);
142+
expect(res.code).toBeUndefined();
143+
});
144+
145+
it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
146+
const runId = await pauseOn(screenFlow('object_form', [], {
147+
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
148+
}));
149+
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
150+
const res = await engine.resume(runId);
151+
expect(res.success).toBe(true);
152+
expect(res.code).toBeUndefined();
153+
});
154+
155+
it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
156+
const runId = await pauseOn(screenFlow('hidden_required', [
157+
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
158+
]));
159+
const res = await engine.resume(runId);
160+
expect(res.success).toBe(true);
161+
expect(res.code).toBeUndefined();
162+
});
163+
});
164+
165+
describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
166+
let engine: AutomationEngine;
167+
let captured: unknown[];
168+
169+
beforeEach(() => {
170+
engine = new AutomationEngine(silentLogger());
171+
installBuiltinNodes(engine, ctx());
172+
captured = [];
173+
// Copies the screen-collected `kind` into the child's declared output.
174+
engine.registerNodeExecutor({
175+
type: 'copier',
176+
async execute(_node, variables) {
177+
variables.set('result', variables.get('kind'));
178+
return { success: true };
179+
},
180+
} as NodeExecutor);
181+
// Parent step after the subflow: captures the mapped output variable.
182+
engine.registerNodeExecutor({
183+
type: 'parentcheck',
184+
async execute(_node, variables) {
185+
captured.push(variables.get('subResult'));
186+
return { success: true };
187+
},
188+
} as NodeExecutor);
189+
engine.registerFlow('child', {
190+
name: 'child',
191+
label: 'Child',
192+
type: 'autolaunched',
193+
variables: [{ name: 'result', type: 'text', isOutput: true }],
194+
nodes: [
195+
{ id: 's', type: 'start', label: 'Start' },
196+
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
197+
{ id: 'copy', type: 'copier', label: 'Copy' },
198+
{ id: 'e', type: 'end', label: 'End' },
199+
],
200+
edges: [
201+
{ id: 'c1', source: 's', target: 'ask' },
202+
{ id: 'c2', source: 'ask', target: 'copy' },
203+
{ id: 'c3', source: 'copy', target: 'e' },
204+
],
205+
} as any);
206+
engine.registerFlow('parent', {
207+
name: 'parent',
208+
label: 'Parent',
209+
type: 'autolaunched',
210+
nodes: [
211+
{ id: 'ps', type: 'start', label: 'Start' },
212+
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
213+
{ id: 'chk', type: 'parentcheck', label: 'Check' },
214+
{ id: 'pe', type: 'end', label: 'End' },
215+
],
216+
edges: [
217+
{ id: 'p1', source: 'ps', target: 'call' },
218+
{ id: 'p2', source: 'call', target: 'chk' },
219+
{ id: 'p3', source: 'chk', target: 'pe' },
220+
],
221+
} as any);
222+
});
223+
224+
it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
225+
const started = await engine.execute('parent', {} as any);
226+
expect(started.status).toBe('paused');
227+
const parentRunId = started.runId!;
228+
// The parent surfaces the CHILD's screen — required `kind` included —
229+
// so the up-bubble below is judged against a screen with a required
230+
// field, and only the engine-built flag lets it through.
231+
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
232+
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
233+
expect(child).toBeDefined();
234+
235+
// Resume the CHILD directly (the approval/wait-style path) with the
236+
// field it asked for; its completion resumes the parent with the
237+
// engine's own output-mapping signal, which never carries `kind`.
238+
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });
239+
240+
expect(childRes.success).toBe(true);
241+
expect(childRes.status).toBeUndefined();
242+
expect(captured).toEqual([{ result: 'escalate' }]);
243+
expect(engine.listSuspendedRuns()).toHaveLength(0);
244+
});
245+
246+
it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
247+
const started = await engine.execute('parent', {} as any);
248+
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
249+
250+
const res = await engine.resume(child.runId);
251+
252+
expect(res.success).toBe(false);
253+
expect(res.code).toBe('INVALID_SCREEN_INPUT');
254+
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
255+
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
256+
expect(captured).toEqual([]);
257+
});
258+
});

packages/services/service-automation/src/engine.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
10041004
* @returns the rejected key names (already in their final, prefixed form).
10051005
* Empty ⇒ every write was applied. An engine-built signal
10061006
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
1007-
* writes the handoff keys, and it is not reachable from a transport.
1007+
* writes the handoff keys, and it is not reachable from a transport. The
1008+
* signal is never absent here — `resume` normalises a missing one to `{}`
1009+
* (#13648), which folds nothing and rejects nothing.
10081010
*/
10091011
function applyResumeSignal(
10101012
variables: Map<string, unknown>,
1011-
signal: ResumeSignal | undefined,
1013+
signal: ResumeSignal,
10121014
nodeId: string,
10131015
): string[] {
1014-
if (!signal) return [];
10151016
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
10161017
const rejected: string[] = [];
10171018
const writes: Array<[string, unknown]> = [];
@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
42074208
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
42084209
const refusal = await this.refuseGatedResume(runId, signal);
42094210
if (refusal) return refusal;
4210-
return this.resumeInternal(runId, signal, false);
4211+
// An ABSENT signal is an EMPTY caller submission, never an exemption
4212+
// (#13648). This is the in-process door, and `resume(runId)` used to
4213+
// skip the screen contract that `resume(runId, {})` is held to:
4214+
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
4215+
// second, unnamed spelling of the exemption the engine already states
4216+
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
4217+
// unconditional `required` field proceeded with that variable unbound.
4218+
// The HTTP door has always assembled `{}` for an empty body; this makes
4219+
// the two doors agree, and the only exemption left is the engine's own
4220+
// continuation, which proves itself by BUILDING an engine-built signal.
4221+
// A pause with no screen contract — `wait`, `approval`, a message-only
4222+
// or object-form screen — is untouched: an empty submission against no
4223+
// declared fields is conformant, so the wait node's timer wake
4224+
// (`engine.resume(runId)`) continues exactly as before.
4225+
return this.resumeInternal(runId, signal ?? {}, false);
42114226
}
42124227

42134228
/**
@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
46684683
*/
46694684
private async resumeInternal(
46704685
runId: string,
4671-
signal: ResumeSignal | undefined,
4686+
// Never `undefined` past the public door: `resume` normalises an
4687+
// absent caller signal to `{}` (#13648), and the engine's own
4688+
// continuations (subflow delegation / up-bubble, `map` re-entry)
4689+
// always hand over a built signal. Typed so, the chokepoints below
4690+
// cannot grow a falsy-signal branch again.
4691+
signal: ResumeSignal,
46724692
skipBubble: boolean,
46734693
childSummary?: FlowRunSummary,
46744694
): Promise<AutomationResult> {
@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
48934913
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
48944914
await this.executeNode(node, flow, variables, context, steps);
48954915
} else {
4896-
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
4916+
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
48974917
}
48984918

48994919
// Collect output variables
@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
50475067
* pass-through `enforceActionParams` gives a param-less action).
50485068
* - **Never an engine-built signal.** The subflow output mapping and the
50495069
* `map` item handoff are the engine's own continuations; they carry
5050-
* author-named output variables, not a screen submission.
5070+
* author-named output variables, not a screen submission. This is the
5071+
* ONLY exemption, and it is spelled once: an absent signal is not a
5072+
* case here — `resume` normalises it to `{}` (#13648) — because a bare
5073+
* `if (!signal)` beside the flag was a second, unnamed spelling of the
5074+
* same exemption that let `resume(runId)` skip every `required` the
5075+
* author wrote.
50515076
*
50525077
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
50535078
* over the run's variables, so a predicate may reference a prior node),
@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
50595084
private refuseInvalidScreenInput(
50605085
run: SuspendedRun,
50615086
runId: string,
5062-
signal: ResumeSignal | undefined,
5087+
signal: ResumeSignal,
50635088
): AutomationResult | null {
5064-
if (!signal) return null;
50655089
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
50665090
if (!screenDeclaresInputContract(run.screen)) return null;
50675091
const fields = run.screen!.fields;

0 commit comments

Comments
 (0)