Skip to content

Commit c395405

Browse files
fix(runtime)!: refuse a flow resume body that carries both inputs and variables (#24594)
Fixes #24546 Clause-②: no (narrowing). Basis: no key, shape, export or error code is added. A resume body the route used to answer `200` while discarding its `variables` bag now answers `400`. ## What changed `POST /api/v1/automation/:name/runs/:runId/resume` accepts `inputs`, `variables`, `output` and `branchLabel`. `variables` is an accepted alias of `inputs`, and the signal assembly reads `b.inputs ?? b.variables`. So a body carrying both keys answered `200 success:true`, the run resumed on the `inputs` bag, and the `variables` bag was never read. That held even when the bag carried a field the screen does not declare, which sent alone is refused `400 INVALID_SCREEN_INPUT`. The lane's pick on the card (claim comment 6269985720) was to **refuse**. The resume arm in `packages/runtime/src/domains/automation.ts` now throws the route's own validation failure for a body carrying both keys: - It sits after the unknown-key refusal (commit a433122) and the value-shape refusal, so both keep their messages for every body. - It sits before `refuseUnrelatedResume` and `resume()`, so the engine is never consulted and the paused run stays parked for a corrected retry. - A key whose value is `undefined` counts as absent, the same reading the value-shape check already uses. - The assembly line `b.inputs ?? b.variables` is unchanged. At most one of the two keys can reach it now, so it drops nothing. A body carrying either key alone is forwarded and judged exactly as before. The `variables` alias is **not** retired or deprecated (a hold-off on the claim). Its producers are counted below for the seat. ## Before and after: the both-keys body, measured through the route Reading taken over a real Hono socket: `LiteKernel` + `HonoServerPlugin` (port 0) + `createDispatcherPlugin({ prefix: '/api/v1' })`, signed in. The automation slot is a scripted engine that plays showcase's `showcase_reassign_wizard` `collect` screen: one declared field, `new_assignee`, and an undeclared key is refused `INVALID_SCREEN_INPUT`. `engineCalls` is what the engine was handed. "Before" swaps in `automation.ts` at the base (`2667e2402`, blob `65c9cffd`) in the working tree only. "After" is this branch's head `eb51ced02` (blob `455f28ad`). The file was restored with `git checkout HEAD`, its blob checked equal to HEAD's and `git diff HEAD` read empty. The scratch measurement file was deleted and is not in the diff. Body `{"inputs":{"new_assignee":"inputs-win@example.com"},"variables":{"new_assignee":"variables-lost@example.com","bogus_key":1}}` (the card's own body) to `POST /api/v1/automation/showcase_reassign_wizard/runs/run_1/resume`: | | status | answer | engine handed | |:--|:--|:--|:--| | before (`2667e2402`) | **200** | `{"success":true,"data":{"success":true,"output":{"assignee":"inputs-win@example.com"}}}` | `{"variables":{"new_assignee":"inputs-win@example.com"}}`, so the `variables` bag was dropped | | after (`eb51ced02`) | **400** | `VALIDATION_FAILED`, the envelope below | nothing: `resume()` never called | The card measured the "before" row on stock showcase at `5b14b7f3` (200, the run completed, the task's assignee was the `inputs` value). After the fix the refusal fires before the engine, so the engine composition plays no part in the "after" row. ## The refusal's envelope The same shape the route's closed-set and value-shape refusals answer: thrown `validationFailure`, which both dispatcher error exits map to `400` + `error.code: 'VALIDATION_FAILED'` + `details.fields[]`. As measured on the socket: ```json {"success":false,"error":{"code":"VALIDATION_FAILED","message":"Invalid resume body — `inputs` and `variables` were both sent; `variables` is an alias of `inputs` (the screen submission), so send `inputs` alone","httpStatus":400,"details":{"fields":[{"field":"inputs","code":"invalid_value","message":"sent together with `variables`, its alias — send `inputs` alone"},{"field":"variables","code":"invalid_value","message":"an alias of `inputs`, sent together with it — send `inputs` alone"}]}}} ``` - Both keys are located, each with its own entry, as the closed-set refusal does for unknown keys. - The field code is `invalid_value`, the ADR-0114 catalog member for "rejected for a reason no other member names". The value is well-shaped; its sibling is the problem. `FieldErrorCode` is a closed catalog in `packages/spec`, so no new member was added (`packages/spec/**` is a hold-off). - It is not `FLOW_FAILED`, which the console reads as terminal. It is on the retryable side with the other body refusals. ## Ablation and controls New test `packages/runtime/src/domains/automation-resume-inputs-variables.test.ts` (10 cases), beside `automation-resume-envelope.test.ts` and `automation-resume-value-shape.test.ts`, on the same harness. It covers: - **Both keys:** the card's body, identical bags, two empty bags, and both keys beside `output` and `branchLabel` all get `400 VALIDATION_FAILED`, with `fields` locating `inputs` and `variables` (`invalid_value`), the message naming both keys and `send \`inputs\` alone`, and `resume` not called. The refusal also escapes `dispatch()` as the recognized validation-failure shape. - **Ordering:** an unknown key is still reported first (`unknown_field`), then a mis-shaped value (`invalid_type`). An `undefined` `variables` is no conflict (200). - **Controls:** `variables` alone with an undeclared field still gets the engine's `400 INVALID_SCREEN_INPUT` (the bag reaches the engine). `variables` alone, valid, gets `200`. `inputs` alone, valid, gets `200`. Ablation, run on the committed fix through `node scripts/ablation-replace.mjs --delete` (WRAP mode, restore armed). The anchor is the whole refusal block, which leaves `b.inputs ?? b.variables` reading as before. The mutation landed with anchor 1 to 0 and blob `455f28ad` to `bed62029`. Running the new file plus the envelope and value-shape files gave `Tests 5 failed | 56 passed (61)`: - Red, all five both-keys cases. The card's body reads `was accepted (answered {"status":200,"body":{"success":true,"data":{"success":true,"output":{"assignee":"inputs-win@example.com"}...}}})`. - Green, the other five cases in the new file and all of `automation-resume-envelope.test.ts` and `automation-resume-value-shape.test.ts`. - Restored: the blob after restore equals HEAD's (`455f28ad`) and `git diff HEAD` is empty. The test imports `../http-dispatcher.js` by relative path, so the subject is `src/`, not a `dist/`. No rebuild leg applies. **One existing test edited (outside the claim's listed file surface).** `automation-resume-value-shape.test.ts` carried `it('keeps the \`variables\` alias, and \`inputs\` still wins when both are sent')`. Its second half pinned exactly the behaviour this card removes (both keys get `200`, `inputs` wins). That half is removed, and the alias half stays as `it('keeps the \`variables\` alias')` with a pointer to the new file. Nothing else in that file moved. ## `variables` producer census Who sends `variables` in the body of this route today. Measured by grep, not guessed. **objectstack at `eb51ced02`.** No production producer. Every hit is a test pinning the alias: - `packages/runtime/src/http-dispatcher.test.ts:490-495`: `{ variables: { note: 'hi' } }`, the alias pin. - `packages/runtime/src/domains/automation-resume-envelope.test.ts:189-195`: the alias pin. - `packages/runtime/src/domains/automation-resume-value-shape.test.ts:95` (`OBJECT_KEYS` includes `variables`, mis-shape refusals) and `:255-263` (the alias pin). - `packages/runtime/src/domains/automation-resume-inputs-variables.test.ts`: this PR's cases. These send `inputs` (or nothing), never `variables`: - client SDK: `client.automation.resume()` (`packages/client/src/index.ts:5906-5923`) and the project-scoped `resume` (`:8237-8250`). The `signal` type declares only `inputs` / `output` / `branchLabel`. - `packages/verify/src/handle.ts:866`: `{ inputs: input ?? {} }`. - dogfood: `flow-durable-suspend.dogfood.test.ts` and `flow-trigger-record-credential-mask.dogfood.test.ts`. - docs: `content/docs/automation/flows.mdx` (`:1034`, `:1172`, `:2288`, whose route table spells the body `{ inputs, output, branchLabel }`) and `content/docs/api/client-sdk.mdx` (`:461`, `:774`). - examples: none. - MCP `resume_run` (`packages/runtime/src/domains/mcp.ts:1000-1001`) builds `signal.variables` in process and never sends this HTTP body. - The only mention of `variables` as a body key outside tests is the released 17.1 notes (`content/docs/releases/v17/17-1.mdx:355`), which list it in the closed set. **objectui**, read-only shallow clone in this session's scratch directory, at `main` `8aa8e3e1` and at the pinned `.objectui-sha` `4016370a`. One producer, `packages/app-shell/src/views/FlowRunner.tsx:446-447`, sends `JSON.stringify({ inputs })`. The other hits are tests asserting the URL only. No file that names the resume route sends `variables`. So the alias has no measured producer outside this repository's own alias pins. Whether to retire it is the seat's question, and this PR does not touch it. ## Tests and gates, at head `eb51ced02` - `pnpm --filter @objectstack/runtime typecheck`: exit 0. That is `tsc --noEmit` plus `check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68 pinned signature(s) held`. The new test file compiles clean under `tsconfig.test.json`. - `@objectstack/runtime` local suite (`vitest run --project local --maxWorkers=2`): `Test Files 423 passed (423)`, `Tests 6655 passed | 19 skipped (6674)`. The `repo` project (`test:repo`) is not touched by this diff and is declared to CI's `Test Core`. - eslint over the three changed `.ts` files (`--no-inline-config`): exit 0, no findings. - `node scripts/pm/dispatch-gates.mjs --commands`: 67 derived commands. Each exit code was captured before any pipe. - First pass: 65 exit 0. `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`, no full build yet). `check:query-options-erasure` exited 1 with an ENOENT on a scratch file of mine that was in the tree for about 20 s while that ESLint scan ran (my error, not a gate finding). - After a full `turbo run build --concurrency=2` (76/76 tasks): those two plus every dist-reading derived gate (`check:dts-closure`, `check:lean-entry-closure`, `check:published-files`, `check:sourcemap-no-sources-content`, `check-dts-emitted --self-test`) were re-run, all exit 0. - `dispatch-gates --ran`: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN` (exit 0). - `check-adr-0087-registration --base origin/main`: the changeset reads `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)` and is accepted. - The 10 declared-wide families (`check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in`, `check:wildcard-fallthrough`): all exit 0. - `pnpm lint` (the full union): `eslint . --no-inline-config` exit 0, no findings (138 s). - Not measured locally, by the derivation's own account: the 4 workflow-variable commands (`check-issue-citations --census`, the three `check-shard-attestation --emit` jobs). They carry CI-only values, and CI runs them. ## Changeset `.changeset/24546-resume-inputs-variables-both-refused.md`: `'@objectstack/runtime': minor`, a `fix(runtime)!:` summary, a line-initial `Clause-②: no (narrowing)`, one ADR-0087 `not-required (no-migration-prescription)` marker with its reason, and the `**BREAKING** (...)` sentence ending "shipped as `minor` under the launch-window convention for breaking changes". ## Review owed before the queue This narrows a public door's accepted bodies, so the seat owes **one review at `CONTRACT_REVIEW_TIER`** before the queue (claim comment 6269985720). It is not run by the dev. This thread is left for that record. ## Acceptance notes - `docs/design/screen-flow-runtime.md:33` still spells the route `POST /api/v1/automation/runs/:runId/resume`, with no `:name` segment. It is a design note, not a reference page. Noted only. - Read while choosing this refusal's field code: two other hand-built body refusals in this package put a code outside the ADR-0114 `FieldErrorCode` catalog on `details.fields[].code`. - `packages/runtime/src/domains/automation.ts:959` (the run-lifecycle `cancel` / `restore-suspension` unknown-key refusal) uses `unrecognized_keys`. Measured through the domain handler, an operator's `cancel` with `{"foo":1}` answers `400 VALIDATION_FAILED` with `fields[0].code: "unrecognized_keys"`, and `ApiErrorSchema` still parses it because `details` is untyped there. - `packages/runtime/src/domains/permission-set-clone.ts:194` uses `invalid_string`. - Not touched here. Reported to the seat in the dev report. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b40780 commit c395405

4 files changed

Lines changed: 298 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: a flow resume body that carries both `inputs` and `variables` is refused with `400 VALIDATION_FAILED`; it no longer answers 200 with the `variables` bag silently dropped
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape, export or error code is added, removed or renamed. The resume route's body is declared by no published schema, and its accepted key set is unchanged: `inputs`, `variables`, `output`, `branchLabel`. The route now refuses one combination it accepted before, a body carrying both `inputs` and `variables`, whose `variables` bag it never read. Nothing stored changes, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (an accept-set narrowing on a public HTTP door: `POST /api/v1/automation/:name/runs/:runId/resume`), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`variables` is an accepted alias of `inputs` on the resume route: both name the screen submission. A body that carried both used to answer `200` with `success: true`, the run resumed on the `inputs` bag, and the `variables` bag was never read, even when it held a field the screen does not declare. Sent alone, that same bag is refused `400 INVALID_SCREEN_INPUT`.
14+
15+
- **Now.** A body carrying both keys answers the route's own validation-failure envelope: `400`, `error.code: 'VALIDATION_FAILED'`, and `details.fields` naming `inputs` and `variables` (code `invalid_value`). The message says to send `inputs` alone. The engine is never consulted, so the paused run stays parked and the same call with a corrected body resumes it.
16+
- **Unchanged.** A body carrying `inputs` alone, or `variables` alone, is forwarded exactly as before, and the engine judges it exactly as before. A key whose value is `undefined` counts as absent, as it already did for the value-shape check. The unknown-key and value-shape refusals keep their messages and come first.
17+
18+
What to do. Send the screen submission under `inputs` only. `@objectstack/client`'s `automation.resume()` and the console's flow runner already send `inputs` alone, and the MCP `resume_run` tool builds its signal in process rather than through this body, so none of them is affected.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24546 — a resume body that carries BOTH `inputs` and `variables` is refused,
5+
* never resolved by dropping one of them.
6+
*
7+
* `variables` is an accepted alias of `inputs` on
8+
* `POST /automation/:name/runs/:runId/resume`, and the signal assembly reads
9+
* `b.inputs ?? b.variables`. A body carrying both therefore answered HTTP 200
10+
* `success:true` with the `variables` bag never read — measured on stock
11+
* showcase through `showcase_reassign_wizard`, whose `collect` screen declares
12+
* one field, `new_assignee`: the run completed with the `inputs` value, while
13+
* the `variables` bag held an undeclared `bogus_key` that, sent alone, is
14+
* refused `400 INVALID_SCREEN_INPUT`. The same silent-drop family the closed
15+
* key set (commit a4331227b, `automation-resume-envelope.test.ts`) and the
16+
* value-shape check (#9416, `automation-resume-value-shape.test.ts`) closed,
17+
* on the one pair of accepted keys that name the same thing.
18+
*
19+
* The lane's pick on the card: REFUSE, with the route's own `400
20+
* VALIDATION_FAILED` envelope (the shape its closed-set and value-shape
21+
* refusals already answer), naming both keys and the one to keep, with the
22+
* engine never consulted so the suspension stays parked for the retry.
23+
*
24+
* The scripted engine below plays the `collect` screen the card measured: it
25+
* judges the submitted bag against the declared `new_assignee` and refuses an
26+
* undeclared key with `INVALID_SCREEN_INPUT`, so each case reads what the
27+
* engine was actually handed. Its verdicts are the engine's own, pinned where
28+
* the engine lives (`service-automation`'s `screen-resume-validation.test.ts`);
29+
* this file pins only the door. Both directions are pinned, because a door that
30+
* refuses everything ships as green as a correct one: the both-keys body is
31+
* refused, and each key alone answers exactly as before.
32+
*/
33+
34+
import { describe, it, expect, vi } from 'vitest';
35+
36+
import type { AutomationResult } from '@objectstack/spec/contracts';
37+
38+
import { HttpDispatcher } from '../http-dispatcher.js';
39+
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
40+
41+
const DECLARED_FIELDS = ['new_assignee'];
42+
43+
/**
44+
* The `collect` screen of `showcase_reassign_wizard`, scripted: one declared,
45+
* required field. An undeclared key is refused the way the engine refuses it;
46+
* a complete bag completes the run and echoes what was written.
47+
*/
48+
function makeDispatcher() {
49+
const resume = vi.fn(async (_runId: string, signal: { variables?: Record<string, unknown> }): Promise<AutomationResult> => {
50+
const bag = signal.variables ?? {};
51+
const undeclared = Object.keys(bag).filter((k) => !DECLARED_FIELDS.includes(k));
52+
if (undeclared.length > 0) {
53+
return {
54+
success: false,
55+
code: 'INVALID_SCREEN_INPUT',
56+
error: `Invalid screen input: Unknown screen field "${undeclared[0]}" — declared fields: 'new_assignee'`,
57+
} as AutomationResult;
58+
}
59+
if (bag.new_assignee === undefined) {
60+
return {
61+
success: false,
62+
code: 'INVALID_SCREEN_INPUT',
63+
error: 'Invalid screen input: Screen field "new_assignee" is required',
64+
} as AutomationResult;
65+
}
66+
return { success: true, output: { assignee: bag.new_assignee }, durationMs: 7 } as AutomationResult;
67+
});
68+
const services: Record<string, unknown> = { automation: { resume } };
69+
const resolve = (name: string) => services[name];
70+
const kernel: any = {
71+
getService: resolve,
72+
getServiceAsync: async (name: string) => resolve(name),
73+
context: { getService: resolve },
74+
};
75+
return { dispatcher: new HttpDispatcher(kernel), resume };
76+
}
77+
78+
const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any);
79+
const RESUME = '/showcase_reassign_wizard/runs/run_1/resume';
80+
81+
/** The card's measured body: both keys, the `variables` bag carrying an undeclared field. */
82+
const CARD_BODY = {
83+
inputs: { new_assignee: 'inputs-win@example.com' },
84+
variables: { new_assignee: 'variables-lost@example.com', bogus_key: 1 },
85+
};
86+
87+
/**
88+
* Drive the resume route with one body and report the refusal as the wire
89+
* would: the status is the one both dispatcher error exits derive for a thrown
90+
* validation failure carrying no `.status` of its own (#3918).
91+
*/
92+
async function refusalFor(body: unknown) {
93+
const { dispatcher, resume } = makeDispatcher();
94+
let thrown: unknown;
95+
let response: unknown;
96+
try {
97+
response = (await dispatcher.handleAutomation(RESUME, 'POST', body, CTX())).response;
98+
} catch (e) {
99+
thrown = e;
100+
}
101+
expect(
102+
thrown,
103+
`resume body ${JSON.stringify(body)} was accepted (answered ${JSON.stringify(response)}) instead of refused`,
104+
).toBeDefined();
105+
const details = validationFailureDetails(thrown);
106+
const status =
107+
typeof (thrown as any)?.status === 'number' ? (thrown as any).status
108+
: details ? VALIDATION_FAILED_STATUS
109+
: 500;
110+
return { details, status, message: (thrown as Error).message, resume };
111+
}
112+
113+
/** Drive the resume route to its answer and hand back what the engine received. */
114+
async function answerFor(body: unknown) {
115+
const { dispatcher, resume } = makeDispatcher();
116+
const result = await dispatcher.handleAutomation(RESUME, 'POST', body, CTX());
117+
return { status: result.response?.status, error: result.response?.body?.error, resume };
118+
}
119+
120+
// ─────────────────────────────────────────────────────────────────────────────
121+
// Direction 1 — the refusal
122+
// ─────────────────────────────────────────────────────────────────────────────
123+
124+
describe('#24546 — a body carrying both `inputs` and `variables` is refused', () => {
125+
it('refuses the card\'s measured body — 400 VALIDATION_FAILED, both keys located, engine never consulted', async () => {
126+
const r = await refusalFor(CARD_BODY);
127+
// The envelope: the route's own validation-failure shape, the one its
128+
// closed-set and value-shape refusals answer.
129+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
130+
expect(r.details?.code).toBe('VALIDATION_FAILED');
131+
// Located: BOTH keys named, each its own entry, with an ADR-0114
132+
// catalog code (`invalid_value`: rejected for a reason no other
133+
// member names — the value is well-shaped, its sibling is the problem).
134+
expect(r.details?.fields).toMatchObject([
135+
{ field: 'inputs', code: 'invalid_value' },
136+
{ field: 'variables', code: 'invalid_value' },
137+
]);
138+
// The message names both keys and the one to keep.
139+
expect(r.message).toMatch(/`inputs`/);
140+
expect(r.message).toMatch(/`variables`/);
141+
expect(r.message).toMatch(/send `inputs` alone/);
142+
// The load-bearing half: nothing reached the engine, so the
143+
// suspension was not consumed and a corrected retry is legitimate.
144+
expect(r.resume).not.toHaveBeenCalled();
145+
});
146+
147+
it.each([
148+
['identical bags', { inputs: { new_assignee: 'ada' }, variables: { new_assignee: 'ada' } }],
149+
['two empty bags', { inputs: {}, variables: {} }],
150+
['both keys beside `output` and `branchLabel`', {
151+
inputs: { new_assignee: 'ada' },
152+
variables: { new_assignee: 'bob' },
153+
output: { comment: 'ok' },
154+
branchLabel: 'approve',
155+
}],
156+
])('refuses %s too — the rule is "both keys present", never "the bags disagree"', async (_label, body) => {
157+
const r = await refusalFor(body);
158+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
159+
expect(r.details?.code).toBe('VALIDATION_FAILED');
160+
expect(r.details?.fields).toMatchObject([{ field: 'inputs' }, { field: 'variables' }]);
161+
expect(r.resume).not.toHaveBeenCalled();
162+
});
163+
164+
it('keeps the earlier refusals first — an unknown key, then a mis-shaped value, each unchanged', async () => {
165+
// Ordering pin: the closed key set (commit a4331227b) and the value
166+
// shapes (#9416) still report exactly what they reported before, so
167+
// a body wrong in two ways gets the earlier correction first.
168+
const unknown = await refusalFor({ ...CARD_BODY, values: { x: 1 } });
169+
expect(unknown.details?.fields).toMatchObject([{ field: 'values', code: 'unknown_field' }]);
170+
171+
const misShaped = await refusalFor({ inputs: 'a string', variables: { new_assignee: 'ada' } });
172+
expect(misShaped.details?.fields).toMatchObject([{ field: 'inputs', code: 'invalid_type' }]);
173+
});
174+
175+
it('reads an `undefined` value as ABSENT — no conflict, the same reading as the value-shape check', async () => {
176+
// `JSON.stringify` drops such a key, so no HTTP caller can produce
177+
// one; the in-process spelling `{ inputs, variables: maybeUndefined }`
178+
// means "no variables".
179+
const { status, resume } = await answerFor({ inputs: { new_assignee: 'ada' }, variables: undefined });
180+
expect(status).toBe(200);
181+
expect(resume).toHaveBeenCalledWith('run_1', { variables: { new_assignee: 'ada' } });
182+
});
183+
184+
it('escapes dispatch() as the recognized validation-failure shape', async () => {
185+
// dispatch() rethrows a domain's validation failure to the HTTP error
186+
// exits #3918 pinned (`dispatcher-validation-error.test.ts`: 400,
187+
// `error.code: 'VALIDATION_FAILED'`, `details.fields[]`).
188+
const { dispatcher, resume } = makeDispatcher();
189+
(dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'user_1' });
190+
let thrown: unknown;
191+
try {
192+
await dispatcher.dispatch(
193+
'POST', `/automation${RESUME}`,
194+
CARD_BODY, {}, {} as any,
195+
);
196+
} catch (e) {
197+
thrown = e;
198+
}
199+
expect(thrown, 'the refusal must reach the HTTP error exits').toBeDefined();
200+
expect(validationFailureDetails(thrown)?.code).toBe('VALIDATION_FAILED');
201+
expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'inputs' }, { field: 'variables' }]);
202+
expect(resume).not.toHaveBeenCalled();
203+
});
204+
});
205+
206+
// ─────────────────────────────────────────────────────────────────────────────
207+
// Direction 2 — the controls: either key ALONE answers exactly as before.
208+
// ─────────────────────────────────────────────────────────────────────────────
209+
210+
describe('#24546 — a body carrying one of the two keys is unaffected', () => {
211+
it('`variables` alone with an undeclared field — still the engine\'s 400 INVALID_SCREEN_INPUT', async () => {
212+
// The alias is live, not dead: sent alone, its bag reaches the engine
213+
// and is judged — which is what made dropping it in combination a
214+
// silent drop rather than a dead key.
215+
const { status, error, resume } = await answerFor({ variables: CARD_BODY.variables });
216+
expect(status).toBe(400);
217+
expect(error?.code).toBe('INVALID_SCREEN_INPUT');
218+
expect(error?.message).toMatch(/bogus_key/);
219+
expect(resume).toHaveBeenCalledWith('run_1', { variables: CARD_BODY.variables });
220+
});
221+
222+
it('`variables` alone, valid — 200, the bag forwarded as the submission', async () => {
223+
const { status, resume } = await answerFor({ variables: { new_assignee: 'variables-only@example.com' } });
224+
expect(status).toBe(200);
225+
expect(resume).toHaveBeenCalledWith('run_1', { variables: { new_assignee: 'variables-only@example.com' } });
226+
});
227+
228+
it('`inputs` alone, valid — 200, the bag forwarded as the submission', async () => {
229+
const { status, resume } = await answerFor({ inputs: { new_assignee: 'inputs-only@example.com' } });
230+
expect(status).toBe(200);
231+
expect(resume).toHaveBeenCalledWith('run_1', { variables: { new_assignee: 'inputs-only@example.com' } });
232+
});
233+
});

‎packages/runtime/src/domains/automation-resume-value-shape.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,14 @@ describe('#9416 — every currently-valid submission still succeeds, unchanged',
252252
});
253253
});
254254

255-
it('keeps the `variables` alias, and `inputs` still wins when both are sent', async () => {
255+
it('keeps the `variables` alias', async () => {
256+
// A body carrying BOTH `inputs` and `variables` used to be accepted
257+
// here with `inputs` winning and `variables` silently dropped. It is
258+
// refused since #24546 — pinned in
259+
// `automation-resume-inputs-variables.test.ts`.
256260
const alias = await acceptedFor({ variables: { note: 'hi' } });
257261
expect(alias.status).toBe(200);
258262
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 } });
263263
});
264264

265265
it.each([

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3252,10 +3252,51 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
32523252
valueFailures,
32533253
);
32543254
}
3255+
// [#24546] BOTH SPELLINGS OF ONE SUBMISSION — the same
3256+
// silent-drop family commit a4331227b and #9416 closed, on the
3257+
// one pair of accepted keys that name the same thing.
3258+
// `variables` is an accepted alias of `inputs`, and the
3259+
// assembly below reads `b.inputs ?? b.variables`, so a body
3260+
// carrying both used to answer 200 `success:true` with the
3261+
// `variables` bag never read — even when that bag held a
3262+
// field the screen does not declare, which sent alone is
3263+
// refused 400 `INVALID_SCREEN_INPUT`. Refused, not resolved by
3264+
// a precedence rule: a precedence would keep a second
3265+
// spelling that means something different only in
3266+
// combination, which is the silence this arm exists to end.
3267+
// The refusal names both keys and the one to keep; a body
3268+
// carrying either key alone is unaffected.
3269+
//
3270+
// Same reading of "present" as the value-shape check above —
3271+
// `undefined` is absent — so the in-process spelling
3272+
// `{ inputs, variables: maybeUndefined }` is not a conflict.
3273+
// Ordering: after the unknown-key and value-shape refusals,
3274+
// whose messages are unchanged for every body, and before
3275+
// `resume()`, so the suspension stays intact for the retry.
3276+
if (b.inputs !== undefined && b.variables !== undefined) {
3277+
throw validationFailure(
3278+
'Invalid resume body — `inputs` and `variables` were both sent; `variables` is an alias of '
3279+
+ '`inputs` (the screen submission), so send `inputs` alone',
3280+
[
3281+
{
3282+
field: 'inputs',
3283+
code: 'invalid_value',
3284+
message: 'sent together with `variables`, its alias — send `inputs` alone',
3285+
},
3286+
{
3287+
field: 'variables',
3288+
code: 'invalid_value',
3289+
message: 'an alias of `inputs`, sent together with it — send `inputs` alone',
3290+
},
3291+
],
3292+
);
3293+
}
32553294
// #3801's field-by-field assembly, unchanged in substance: the
32563295
// body is never spread, so the symbol-keyed service-authority
32573296
// marker stays unforgeable. Every surviving value is now known
3258-
// to match the contract, so presence is the only test left.
3297+
// to match the contract, so presence is the only test left —
3298+
// and at most one of `inputs` / `variables` is present (the
3299+
// refusal just above), so the alias read drops nothing.
32593300
const inputs = (b.inputs ?? b.variables);
32603301
const signal: any = {};
32613302
if (inputs !== undefined) signal.variables = inputs;

0 commit comments

Comments
 (0)