Uh oh!
There was an error while loading. Please reload this page.
fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused - #9419
Conversation
The resume route's outer envelope becomes a closed set — exactly `inputs` / `variables` / `output` / `branchLabel` (maintainer ruling 2026-08-15, Option A on #8796). An unknown top-level key is refused with a located 400 naming the offending key(s) and the accepted set, thrown as the duck-typed validation failure both dispatcher error exits map to 400 VALIDATION_FAILED + fields[] (#3918) — the same wire shape the toggle arm's closed set answers, and deliberately not FLOW_FAILED: this refusal never reaches the engine, the suspension stays intact, and the caller can retry with a corrected body. The refusal wraps #3801's field-by-field signal assembly unchanged, and runs before the service call (#3899: nothing reaches the engine until the body is legal). Co-Authored-By: Claude <noreply@anthropic.com>
… the launch-window lockstep rule Every publishable package is in one Changesets fixed group, so a single major promotes the whole monorepo; check-changeset-no-major prescribes minor for a breaking change during the launch window. The body keeps the BREAKING framing and answers the ADR-0087 disposition in writing. Co-Authored-By: Claude <noreply@anthropic.com>
…ume-envelope-closed-set
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8796
Maintainer ruling 2026-08-15 (Option A, verbatim on the issue: 「接受你的所有建议。」): the resume route's request body becomes a closed set — exactly
inputs/variables/output/branchLabel. An unknown top-level key is refused with a located400naming the offending key(s) and the accepted set.What was measured (premise re-verified on
bc6434b5b, post-#8684)POST .../runs/:runId/resumewith{"nodeId":"ask","values":{...}}— no key of which the route reads — answered HTTP 200success:truewith the screen submission treated as empty: the run completed and the submitted value never reached the flow. Re-measured by reverse verification from the committed fix: withpackages/runtime/src/domains/automation.tsrestored toorigin/main, all 5 refusal pins in the new test file go red in exactly that shape (engine called with an empty signal, 200), while all 5 preservation pins stay green. The premise held after #8684 landed; the accepted set is still exactly the four keys (walked every body read in the resume arm — nothing else is consumed).The refusal's code — deliberately NOT
FLOW_FAILEDThe refusal is thrown as the duck-typed validation failure both dispatcher error exits map to
400+error.code: 'VALIDATION_FAILED'+details.fields[](#3918;dispatcher-validation-error.test.tspins that mapping end-to-end for both exits) — the same wire shape the toggle arm's closed body set in this same file already answers, and a code the ADR-0112 ledger already registers for@objectstack/runtime. Reasoning, per the objectui contract recorded on #8684 (PR objectui#4899): the console treats 400FLOW_FAILEDas terminal — the engine consumed the suspension and the run actually ran. This refusal is the opposite case: the request never reaches the engine, the suspension is untouched, and the caller can legitimately retry with a corrected body. It therefore sits withINVALID_SIGNAL/INVALID_SCREEN_INPUTon the retryable side, and reusingFLOW_FAILEDwould have told the console a live run is dead. Eachdetails.fields[]entry carries the ADR-0114 catalog codeunknown_field.Seam and ordering
automationService.resume()— 请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899's "nothing reaches the service until the body is legal", the same ordering the toggle arm enforces. Consequence: a body that is both malformed and unauthorized answers the envelope400; every engine verdict (PERMISSION_DENIED,INVALID_SIGNAL,INVALID_SCREEN_INPUT,RUN_NOT_FOUND, ...) is judged only on a legal envelope. That is the contract the closed-parameter-set policy implies: the refusal's whole point is that the service was never called.FLOW_FAILEDarm,RUN_NOT_FOUNDstale-suspension arm) is untouched, and the trigger routes (automation: bothtriggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378) are untouched.Wire shape, pinned
New test file
packages/runtime/src/domains/automation-resume-envelope.test.ts(10 tests), following theautomation-body-validation.test.tsprecedent:values, a half-wrong body (validinputsbeside an unknown sibling — Option B was explicitly declined), and a non-empty array body: each names every offending key and the full accepted set, carriesunknown_fieldfield entries, isVALIDATION_FAILED(asserted ≠FLOW_FAILED), and the service is never called.variablesalias; empty object /undefined/nullbodies still resume with an empty signal (a legal empty submission for a screen whose declared fields are all optional).dispatch()as the exact shape both HTTP error exits recognise (validationFailureDetails), with the service never called.Known residue, filed rather than fixed (out of the ruling's scope — it covers unknown keys): a type-mismatched value on an accepted key is still silently dropped; recorded as #9416, which remains open and is not addressed here.
Verification (local, at head
ef513cf07, after mergingmain@40d5b2d4c)@objectstack/runtimefull suite: 166 files / 2477 tests passed — including every automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 pin (none moved) and the 10 new pins.pnpm --filter @objectstack/runtime typecheck(tsc --noEmit): clean.origin/main→ 5 refusal pins red / 5 preservation pins green → restored; direction as expected (refusals turned red because the lenient envelope accepted the bodies).scripts/pm/dispatch-gates.mjs) and run at head:check:cross-package-test-inputs,check:route-envelope,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,docs-audit/check-affected-docs,check:nul-bytes, plus the convention-triggered test family the derivation added over the dispatch list:check:query-options-erasure,check:type-check-coverage,check:type-check-debt(workspace closure built first),check:engine-double-contract,check:where-matcher— all green.Changeset
BREAKING framing, shipped as
minorper the launch-window lockstep rule (check-changeset-no-majorprescribes it; every publishable package is one fixed group). ADR-0087 disposition answered in writing:not-required (no-migration-prescription)— no metadata surface changes shape; the only channel that reaches raw-HTTP callers is the changeset itself.Generated by Claude Code