Skip to content

Commit 96f397a

Browse files
os-zhuangclaude
andauthored
fix(runtime): resume refuses a type-mismatched value on an accepted key instead of dropping it (#9416) (#9685)
`POST /automation/:name/runs/:runId/resume` type-guarded each accepted key and silently skipped a value that failed the guard, so `{"inputs":"a string"}` passed the closed key set (#8796), lost its value, and answered HTTP 200 `success:true` with the submission treated as empty. Same for `{"output":42}`, `{"branchLabel":7}`, a non-object JSON body and an empty-array body. Option A as ruled: 400, located, naming the key and the expected type, reusing the file's own `validationFailure` helper and the ADR-0114 `invalid_type` / `unknown_field` catalog members. Non-object and array bodies refuse the same way. Not Option B: `ResumeSignal` types `variables`/`output` as `Record<string, unknown>` and `branchLabel` as `string`, so forwarding hands a service a shape its own contract excludes. Every already-valid submission still succeeds with byte-identical arguments at the service, including the bodyless resume and the `variables` alias. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e2f594 commit 96f397a

3 files changed

Lines changed: 463 additions & 8 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
**BREAKING**`POST /api/v1/automation/:name/runs/:runId/resume` refuses an accepted
6+
key carrying a value of the wrong TYPE, and refuses a request body that is not a JSON
7+
object. This is the value axis of the closed envelope the same route already applies to
8+
its KEYS.
9+
10+
Until now the route type-guarded each accepted key and silently skipped whatever failed
11+
the guard, so `{"inputs":"a string"}` passed the closed key set (the key IS accepted),
12+
lost its value, and answered HTTP 200 `success:true` with the submission treated as
13+
empty: the run completed and the caller was told its screen input landed when nothing
14+
did. Identical for `{"output":42}`, `{"branchLabel":7}`, a JSON string/number/boolean
15+
body, and an empty-array body.
16+
17+
What changes on the wire:
18+
19+
- **`inputs` / `variables` / `output` must each be a JSON object ⇒ anything else is
20+
`400` with `error.code: 'VALIDATION_FAILED'`.** `error.details.fields[]` carries one
21+
`invalid_type` entry per offending key, and both the entry and the message name the
22+
key and the expected type (plus the type actually received). `null` and an array are
23+
refused too — the engine's `ResumeSignal` contract types these as
24+
`Record<string, unknown>`, which excludes both, and an array used to be forwarded to a
25+
service whose own contract rejects it.
26+
- **`branchLabel` must be a JSON string ⇒ anything else is the same located `400`.**
27+
- **A body that is not a JSON object ⇒ the same `400`, located at `(body)`**, naming the
28+
accepted keys. That covers a JSON string, number or boolean body, and an array —
29+
including the empty array, which previously slipped past the key check because it has
30+
no keys to be unknown.
31+
- Every refusal happens **before** the flow engine is consulted, so the suspension is
32+
untouched and the same request with a corrected body is expected to succeed. Like the
33+
unknown-key refusal it sits on the retryable side beside `INVALID_SIGNAL` and
34+
`INVALID_SCREEN_INPUT`, and is deliberately not `FLOW_FAILED` (which the console
35+
treats as terminal, because it means the engine consumed the suspension and ran).
36+
- **Unchanged:** every submission that was already well-typed behaves exactly as before,
37+
with byte-identical arguments at the service — all four accepted keys, the `variables`
38+
alias, `inputs` winning when both are sent, empty objects, an empty-string
39+
`branchLabel`, and the bodyless resume (`{}` / absent / `null` body), which stays a
40+
legal empty submission. The inner bag is still forwarded verbatim for the engine to
41+
judge — reserved-name and declared-field verdicts did not move into the transport. The
42+
signal is still assembled field-by-field, never a body spread, so the
43+
service-authority marker stays unforgeable.
44+
45+
A key spelled with an `undefined` value counts as absent rather than mis-shaped:
46+
`JSON.stringify` drops such a key, so no HTTP caller can produce one, and the in-process
47+
spelling `{ inputs: maybeUndefined }` means "no inputs".
48+
49+
Any client already sending well-typed values is unaffected. A client sending a
50+
mis-shaped value now gets the located 400 above instead of a 200 reporting success on a
51+
submission that was thrown away.
52+
53+
<!-- adr-0087: not-required (no-migration-prescription) retires no metadata surface: no Zod schema, no authorable key, and no stored sys_metadata row changes shape, so `objectstack migrate meta` has nothing to rewrite and no ledger entry can be written for it. What changes is which HTTP request bodies one route accepts, and the only channel that reaches those callers is this changeset itself — the same disposition its sibling closed-key-set change carried. -->
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #9416 — the resume body refuses a MIS-SHAPED VALUE on an accepted key, and a
5+
* body that is not a JSON object at all.
6+
*
7+
* #8796 closed the resume envelope's KEY set; this is the same silent-drop
8+
* family one axis over, on the VALUE. The assembly type-guarded each accepted
9+
* key and skipped whatever failed the guard, so `{"inputs":"a string"}` passed
10+
* the closed key set (the key IS accepted), lost its value, and answered HTTP
11+
* 200 `success:true` with the submission treated as EMPTY — the run completed
12+
* and the caller was told its screen input landed when nothing did. Identical
13+
* for `{"output":42}`, `{"branchLabel":7}`, a non-object JSON body, and an
14+
* EMPTY array body (a non-empty one was already refused, because its indices
15+
* read as unknown keys).
16+
*
17+
* Maintainer ruling on the card — **Option A**: refuse, 400, located, naming
18+
* the key and the expected type; non-object and array bodies refuse the same
19+
* way. It inherits #8796's ruling together with its reason, plus #3899's
20+
* toggle-arm precedent (a truthy non-boolean `enabled` is refused there, never
21+
* coerced or dropped). ⛔ Option B — forward the raw value and let the engine
22+
* judge — was rejected: `ResumeSignal` types `variables`/`output` as
23+
* `Record<string, unknown>` and `branchLabel` as `string`, so forwarding hands
24+
* a service a shape its own contract excludes.
25+
*
26+
* BOTH directions are pinned here, because a door that refuses everything ships
27+
* just as green as a correct one: every refused shape answers 400 naming the
28+
* key and the expected type, AND every currently-valid submission still
29+
* succeeds with byte-identical arguments at the service.
30+
*/
31+
32+
import { describe, it, expect, vi } from 'vitest';
33+
34+
import { HttpDispatcher } from '../http-dispatcher.js';
35+
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
36+
37+
function makeDispatcher() {
38+
const spies = {
39+
resume: vi.fn(async () => ({ success: true, output: {}, durationMs: 7 })),
40+
};
41+
const services: Record<string, unknown> = { automation: spies };
42+
const resolve = (name: string) => services[name];
43+
const kernel: any = {
44+
getService: resolve,
45+
getServiceAsync: async (name: string) => resolve(name),
46+
context: { getService: resolve },
47+
};
48+
return { dispatcher: new HttpDispatcher(kernel), spies };
49+
}
50+
51+
const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any);
52+
const RESUME = '/flow_a/runs/run_1/resume';
53+
54+
/**
55+
* Drive the resume route with one body and report the refusal as the wire
56+
* would: the status is the one both dispatcher error exits derive for a thrown
57+
* validation failure carrying no `.status` of its own (#3918).
58+
*/
59+
async function refusalFor(body: unknown) {
60+
const { dispatcher, spies } = makeDispatcher();
61+
let thrown: unknown;
62+
let response: unknown;
63+
try {
64+
response = (await dispatcher.handleAutomation(RESUME, 'POST', body, CTX())).response;
65+
} catch (e) {
66+
thrown = e;
67+
}
68+
expect(
69+
thrown,
70+
`resume body ${JSON.stringify(body)} was accepted (answered ${JSON.stringify(response)}) instead of refused`,
71+
).toBeDefined();
72+
const details = validationFailureDetails(thrown);
73+
const status =
74+
typeof (thrown as any)?.status === 'number' ? (thrown as any).status
75+
: details ? VALIDATION_FAILED_STATUS
76+
: 500;
77+
return { details, status, message: (thrown as Error).message, code: (thrown as any).code, spies };
78+
}
79+
80+
/** Drive the resume route to its 200 and hand back what the service received. */
81+
async function acceptedFor(body: unknown) {
82+
const { dispatcher, spies } = makeDispatcher();
83+
const result = await dispatcher.handleAutomation(RESUME, 'POST', body, CTX());
84+
return { status: result.response?.status, spies };
85+
}
86+
87+
// ─────────────────────────────────────────────────────────────────────────────
88+
// Direction 1 — the refusal
89+
// ─────────────────────────────────────────────────────────────────────────────
90+
91+
describe('#9416 — a type-mismatched value on an accepted key is refused, not dropped', () => {
92+
// The card's measured shapes, plus the two JSON values that used to vanish
93+
// most quietly: an explicit `null`, and an array (which the old
94+
// `typeof === "object"` guard forwarded to a contract that excludes it).
95+
const OBJECT_KEYS = ['inputs', 'variables', 'output'] as const;
96+
const NON_OBJECTS: Array<[string, unknown]> = [
97+
['a string', 'a string'],
98+
['a number', 42],
99+
['a boolean', true],
100+
['null', null],
101+
['an array', [1, 2]],
102+
];
103+
104+
describe.each(OBJECT_KEYS)('`%s` must be an object', (key) => {
105+
it.each(NON_OBJECTS)('refuses %s — 400, located, naming the expected type', async (_label, value) => {
106+
const r = await refusalFor({ [key]: value });
107+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
108+
expect(r.details?.code).toBe('VALIDATION_FAILED');
109+
// Located: the offending key is named, with an ADR-0114 catalog code.
110+
expect(r.details?.fields).toMatchObject([{ field: key, code: 'invalid_type' }]);
111+
// …and the EXPECTED TYPE is named, in the field entry and the message.
112+
expect((r.details?.fields[0] as any).message).toMatch(/expected an object/);
113+
expect(r.message).toMatch(new RegExp(`\`${key}\``));
114+
expect(r.message).toMatch(/expected an object/);
115+
// The suspension was never consulted, so a corrected retry is legitimate.
116+
expect(r.spies.resume).not.toHaveBeenCalled();
117+
});
118+
});
119+
120+
const NON_STRINGS: Array<[string, unknown]> = [
121+
['a number', 7],
122+
['a boolean', false],
123+
['null', null],
124+
['an object', { label: 'approve' }],
125+
['an array', ['approve']],
126+
];
127+
128+
it.each(NON_STRINGS)('`branchLabel` must be a string — refuses %s', async (_label, value) => {
129+
const r = await refusalFor({ branchLabel: value });
130+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
131+
expect(r.details?.fields).toMatchObject([{ field: 'branchLabel', code: 'invalid_type' }]);
132+
expect((r.details?.fields[0] as any).message).toMatch(/expected a string/);
133+
expect(r.message).toMatch(/`branchLabel`/);
134+
expect(r.message).toMatch(/expected a string/);
135+
expect(r.spies.resume).not.toHaveBeenCalled();
136+
});
137+
138+
it('names EVERY mis-shaped key, not just the first', async () => {
139+
const r = await refusalFor({ inputs: 'a string', output: 42, branchLabel: 7 });
140+
expect(r.details?.fields).toMatchObject([
141+
{ field: 'inputs', code: 'invalid_type' },
142+
{ field: 'output', code: 'invalid_type' },
143+
{ field: 'branchLabel', code: 'invalid_type' },
144+
]);
145+
expect(r.message).toMatch(/`inputs`/);
146+
expect(r.message).toMatch(/`output`/);
147+
expect(r.message).toMatch(/`branchLabel`/);
148+
});
149+
150+
it('reports the received type too, so the caller sees what it sent', async () => {
151+
expect((await refusalFor({ inputs: 'x' })).message).toMatch(/received a string/);
152+
expect((await refusalFor({ output: 42 })).message).toMatch(/received a number/);
153+
expect((await refusalFor({ inputs: null })).message).toMatch(/received null/);
154+
expect((await refusalFor({ output: [] })).message).toMatch(/received an array/);
155+
expect((await refusalFor({ branchLabel: 7 })).message).toMatch(/received a number/);
156+
});
157+
158+
it('stays off FLOW_FAILED — this refusal leaves the suspension live', async () => {
159+
// ⚠️ #8684 hazard pin: the console treats 400 FLOW_FAILED as terminal
160+
// (the wizard closes — objectui PR #4899). The engine was never
161+
// consulted here, so the pause is intact and the caller can retry.
162+
const r = await refusalFor({ inputs: 'a string' });
163+
expect(r.code).toBe('VALIDATION_FAILED');
164+
expect(r.code).not.toBe('FLOW_FAILED');
165+
});
166+
167+
it('escapes dispatch() as the recognized validation-failure shape', async () => {
168+
const { dispatcher, spies } = makeDispatcher();
169+
(dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'user_1' });
170+
let thrown: unknown;
171+
try {
172+
await dispatcher.dispatch(
173+
'POST', '/automation/flow_a/runs/run_1/resume',
174+
{ inputs: 'a string' }, {}, {} as any,
175+
);
176+
} catch (e) {
177+
thrown = e;
178+
}
179+
expect(thrown, 'the refusal must reach the HTTP error exits').toBeDefined();
180+
expect(validationFailureDetails(thrown)?.code).toBe('VALIDATION_FAILED');
181+
expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'inputs' }]);
182+
expect(spies.resume).not.toHaveBeenCalled();
183+
});
184+
185+
it('refuses the mis-shaped value even when a sibling key is perfectly valid', async () => {
186+
// The half-wrong body must not be silently half-dropped — the same
187+
// reasoning #8796 used to decline "refuse only when nothing is
188+
// recognized".
189+
const r = await refusalFor({ inputs: { real: 'value' }, branchLabel: 7 });
190+
expect(r.details?.fields).toMatchObject([{ field: 'branchLabel', code: 'invalid_type' }]);
191+
expect(r.spies.resume).not.toHaveBeenCalled();
192+
});
193+
194+
it('reports an unknown KEY ahead of a mis-shaped value — #8796 message unchanged', async () => {
195+
// Ordering pin: a body that is both misspelled and mis-shaped still
196+
// reports the misspelling, which is the correction the caller needs
197+
// first and the one #8796 pinned.
198+
const r = await refusalFor({ inputs: 'a string', values: { x: 1 } });
199+
expect(r.details?.fields).toMatchObject([{ field: 'values', code: 'unknown_field' }]);
200+
expect(r.message).toMatch(/`values`/);
201+
});
202+
});
203+
204+
describe('#9416 — a body that is not a JSON object is refused', () => {
205+
const NON_OBJECT_BODIES: Array<[string, unknown]> = [
206+
['a JSON string', 'a string'],
207+
['a JSON number', 42],
208+
['a JSON boolean', true],
209+
['an EMPTY array', []],
210+
];
211+
212+
it.each(NON_OBJECT_BODIES)('refuses %s — 400 at `(body)`, naming the accepted keys', async (_label, body) => {
213+
const r = await refusalFor(body);
214+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
215+
expect(r.details?.code).toBe('VALIDATION_FAILED');
216+
// `(body)` is the root-level locator — a body of the wrong type has no
217+
// path to point at (`fieldsFromZodIssues`' own convention).
218+
expect(r.details?.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]);
219+
expect(r.message).toMatch(/expected an object/);
220+
expect(r.message).toMatch(/`inputs`/);
221+
expect(r.message).toMatch(/`branchLabel`/);
222+
expect(r.spies.resume).not.toHaveBeenCalled();
223+
});
224+
225+
it('refuses a NON-empty array too — the #8796 arm keeps working, now located at `(body)`', async () => {
226+
// Previously caught by the key check (indices read as unknown keys);
227+
// now caught one step earlier, by the shape it actually is.
228+
const r = await refusalFor([{ inputs: {} }]);
229+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
230+
expect(r.details?.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]);
231+
expect(r.spies.resume).not.toHaveBeenCalled();
232+
});
233+
});
234+
235+
// ─────────────────────────────────────────────────────────────────────────────
236+
// Direction 2 — the half a regression does not redden: everything valid still
237+
// works, with the arguments the service always received.
238+
// ─────────────────────────────────────────────────────────────────────────────
239+
240+
describe('#9416 — every currently-valid submission still succeeds, unchanged', () => {
241+
it('forwards all four accepted keys exactly as before', async () => {
242+
const { status, spies } = await acceptedFor({
243+
inputs: { new_assignee: 'ada' },
244+
output: { comment: 'ok' },
245+
branchLabel: 'approve',
246+
});
247+
expect(status).toBe(200);
248+
expect(spies.resume).toHaveBeenCalledWith('run_1', {
249+
variables: { new_assignee: 'ada' },
250+
output: { comment: 'ok' },
251+
branchLabel: 'approve',
252+
});
253+
});
254+
255+
it('keeps the `variables` alias, and `inputs` still wins when both are sent', async () => {
256+
const alias = await acceptedFor({ variables: { note: 'hi' } });
257+
expect(alias.status).toBe(200);
258+
expect(alias.spies.resume).toHaveBeenCalledWith('run_1', { variables: { note: 'hi' } });
259+
260+
const both = await acceptedFor({ inputs: { a: 1 }, variables: { b: 2 } });
261+
expect(both.status).toBe(200);
262+
expect(both.spies.resume).toHaveBeenCalledWith('run_1', { variables: { a: 1 } });
263+
});
264+
265+
it.each([
266+
['empty object', {}],
267+
['undefined body', undefined],
268+
['null body', null],
269+
])('still accepts %s as an empty submission', async (_label, body) => {
270+
// The bodyless resume is legal (a screen whose declared fields are all
271+
// optional). The refusal is about a value that is WRONG, never about a
272+
// value that is absent.
273+
const { status, spies } = await acceptedFor(body);
274+
expect(status).toBe(200);
275+
expect(spies.resume).toHaveBeenCalledWith('run_1', {});
276+
});
277+
278+
it.each([
279+
['an empty inputs object', { inputs: {} }, { variables: {} }],
280+
['an empty output object', { output: {} }, { output: {} }],
281+
['an empty-string branchLabel', { branchLabel: '' }, { branchLabel: '' }],
282+
])('accepts %s — empty is a legal value of the right type', async (_label, body, expected) => {
283+
const { status, spies } = await acceptedFor(body);
284+
expect(status).toBe(200);
285+
expect(spies.resume).toHaveBeenCalledWith('run_1', expected);
286+
});
287+
288+
it('treats an `undefined` value as ABSENT, not mis-shaped', async () => {
289+
// `JSON.stringify` drops such a key, so no HTTP caller can produce
290+
// one; the in-process spelling `{ inputs: maybeUndefined }` means "no
291+
// inputs" and must not become a 400.
292+
const { status, spies } = await acceptedFor({ inputs: undefined, branchLabel: undefined });
293+
expect(status).toBe(200);
294+
expect(spies.resume).toHaveBeenCalledWith('run_1', {});
295+
});
296+
297+
it('still forwards the INNER bag verbatim — the engine, not the route, judges its contents', async () => {
298+
// The refusal is about the value's TYPE only. Reserved-name and
299+
// declared-field verdicts stay in the engine (#3853, #4477), at the one
300+
// place a signal reaches the variable map.
301+
const { status, spies } = await acceptedFor({
302+
inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3, nested: { deep: [1, 2] } },
303+
output: { decision: 'ok', $internal: true },
304+
});
305+
expect(status).toBe(200);
306+
expect(spies.resume).toHaveBeenCalledWith('run_1', {
307+
variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3, nested: { deep: [1, 2] } },
308+
output: { decision: 'ok', $internal: true },
309+
});
310+
});
311+
});

0 commit comments

Comments
 (0)