Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): resume refuses a type-mismatched value on an accepted key instead of dropping it (#9416) - #9685
Conversation
…ey instead of dropping it (#9416) `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>
…ume-value-type-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 5 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 — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin be3b8d70c4f75fe24cc17bcd515808d5427a61f5 && git checkout be3b8d70c4f75fe24cc17bcd515808d5427a61f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fc89098aa1ceb2cca60b1c1204248f445156de5d 155e19388105e8aafb30c0f62df17d18b6a3627c && git checkout -B drift-repro fc89098aa1ceb2cca60b1c1204248f445156de5d && git merge --no-ff 155e19388105e8aafb30c0f62df17d18b6a3627c
node scripts/docs-audit/affected-docs.mjs --json fc89098aa1ceb2cca60b1c1204248f445156de5d
|
os-zhuang
commented
Aug 18, 2026
PM review — accepted, ready + auto-merge armed. Both judgement calls confirmed.
⭐ The ablation is the strongest this lane has seen todayPredicted in writing before running: 30 red / 11 green in the new file, with the #8796 sibling file staying 10/10 green. Observed: What makes it more than a lucky number: it named the one new-file test it predicted would stay green (the ordering pin "reports an unknown KEY ahead of a mis-shaped value") and explained why it cannot redden for this defect — the #8796 unknown-key arm survives the ablation. A prediction that also says what will not move is the one that can be wrong; this one could have been, and wasn't.
|
Fixes#9416
POST /api/v1/automation/:name/runs/:runId/resumetype-guarded each accepted key andsilently skipped a value that failed the guard. So
{"inputs": "a string"}passed#8796's closed KEY set — the key IS accepted — lost its value in the guard, reached the
engine as an empty signal, and answered HTTP 200
success:truewith the submissiontreated as empty: the run completed and the caller was told its screen input landed
when nothing did. Identical for
{"output": 42},{"branchLabel": 7}, a non-object JSONbody, and an empty-array body.
That is exactly the failure shape #8796 measured for unknown keys, reached through a
well-spelled key with a mis-shaped value.
The ruling implemented
Option A, as ruled on the card: refuse a type-mismatched value on an accepted key —
400, located, naming the key and the expected type. Non-object and array bodies refuse
the same way. It inherits #8796's closed-key-set ruling together with its reason, plus
the #3899 toggle-arm precedent (a truthy non-boolean
enabledis refused there, nevercoerced or dropped).
Option B — forward the raw value and let the engine judge — was rejected on the card and
is rejected in the code comment for the reason that decided it:
ResumeSignaltypesvariables/outputasRecord[string, unknown]andbranchLabelasstring, soforwarding hands a service a shape its own contract excludes. An array is in that
set, and the old
typeof === 'object'guard forwarded it.No new envelope and no new code: the refusal reuses the file's own
validationFailurehelper and the
invalid_type/unknown_fieldADR-0114 catalog members the sibling armsalready answer with. Both dispatcher error exits map it to
400 VALIDATION_FAILED+details.fields[](#3918).What changed
packages/runtime/src/domains/automation.ts, the resume signal-assembly block only:body ?? {}must be a non-array object. A JSONstring/number/boolean body and an empty array used to normalise to
{}and answer200 on an empty signal; they now answer 400 located at
(body), naming the acceptedkeys.
undefined/nullstay the legal bodyless resume.checks — a body that is both misspelled and mis-shaped reports the misspelling, which
is the correction the caller needs first.
inputs/variables/outputmust each be a JSON object;branchLabelmust be a string. Every offending key gets its owninvalid_typeentry,and both the entry and the message name the key and the expected type (plus the type
received).
automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801's symbol-keyed service-authority marker stays unforgeable. Every surviving value
is now known to match the contract, so presence is the only test left.
A key whose value is
undefinedcounts as absent, not mis-shaped:JSON.stringifydrops such a key, so no HTTP caller can produce one, and the in-process spelling
{ inputs: maybeUndefined }means "no inputs". An explicitnullis refused — JSONcan express it, and it was the value dropped most quietly of all.
All six shapes route through one block
Verified, because a refusal added in one place while another limb still drops silently is
a half-fix.
POST /automation/:name/runs/:runId/resumehas one door: the route ledgerregisters it once,
dispatcher-plugin.tsforwardsreq.bodytodispatcher.dispatch→handleAutomation→ this arm, andpackages/resthas no resume route (the approvaldecision path goes through
ApprovalServicein-process, not through this body assembly).All four value-shape cases and both body-shape cases enter at the same two statements.
Verification — both directions, then ablation
Refusal direction: every refused shape answers 400 with the key and the expected type
named, and the engine is never consulted (so the suspension stays live and a corrected
retry is legitimate). It also stays off
FLOW_FAILED, which the console treats asterminal (#8684 / objectui PR #4899).
Preservation direction — the half a regression does not redden: every already-valid
submission still succeeds with byte-identical arguments at the service — all four
keys, the
variablesalias,inputswinning when both are sent, empty objects, anempty-string
branchLabel, the bodyless resume, and the inner bag still forwardedverbatim for the engine to judge.
Ablation (restore the silent-skip guards, predict, run, restore):
Run:
30 failed | 21 passed (51)across the two resume test files — the 21 green are the11 predicted green in the new file plus all 10 of the #8796 sibling file, which the
ablation leaves untouched and which stayed
1 passedat the file level.The one new-file test predicted green under ablation is the ordering pin
("reports an unknown KEY ahead of a mis-shaped value"): the #8796 unknown-key arm survives
the ablation, so that test cannot redden for this defect and is not claimed to. Every
other refusal test reddened, including the non-empty-array one — its locator moves from
the key check back to
(body), which is a real observable difference, not a vacuous pass.Ablation applied with
git checkout HEAD~1 -- packages/runtime/src/domains/automation.ts(the branch point is exactly the pre-fix file) and restored with
git checkout claude/issue-9416-resume-value-type-refusal -- ...; the post-restore diffagainst the branch was empty.
Gates
Local gates were derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, re-derived after themainmerge, and all run green:pnpm --filter @objectstack/runtime typecheck— cleanpnpm --filter @objectstack/runtime test— 171 files, 2569 tests passedpnpm check:route-envelope·check:cross-package-test-inputs·check:changeset-gate-self-tests·
check:objectui-changeset·check:nul-bytesnode scripts/check-adr-0087-registration.mjs(1 declared-breaking changeset, dispositionpresent) ·
check-changeset-no-major.mjs·check-empty-changeset.mjs·
check-cross-package-test-inputs.mjs·docs-audit/check-affected-docs.mjscheck:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coverage·check:type-check-debt(closure built first; 33 ledger entries re-measured,surplus: none)
...@objectstack/runtime):@objectstack/client310 tests ·@objectstack/hono73 ·@objectstack/http-conformance72·
@objectstack/dogfoodflow-durable-suspend11 — the only consumer that POSTs a realresume body over HTTP. All green.
packages/runtime/tsconfig.jsonexcludes**/*.test.ts,so
pnpm --filter @objectstack/runtime typecheckdoes not read the new test file. Thatpackage carries a 227-error
TEST_DEBTentry for its hidden test layer, and the re-measureabove reports surplus: none — so the new file contributes zero new type errors. The
exclusion itself is pre-existing package state, out of this card's region, and not touched
here.
Verified at
155e19388.Generated by Claude Code