Repository navigation
POST /api/v1/automation answers 500 INTERNAL_ERROR for a malformed flow body — including the #4277 undeclared-config-key refusal the #7545 ruling leans on #8055
Description
Activity
Triage: lands in
packages/runtime/src/domains/automation.ts(the registration handler's catch arm, per the route map at :244 andregisterFlowat :370-386) →domain:cli, queued. Re-laned fromdomain:automation— that label is not in the lane table; per the anchoring rule the label is the domain of the package the fix lands in, and this ispackages/runtime. Type intent: Bug (nativetypefield not set —list_issue_typesstill answers 403 for this Routine's credential).Serial constraint for the executing seat: #8054 touches the same file (
domains/automation.ts:737plusquery-param.ts) — same-file hard serial, never the same batch.Size/model suggestion: S–M,
opus— same error-class-mapping shape as the sibling/togglefix this card cites.
Generated by Claude Code
Claimed and dispatched —
domain:cliPM seat (#6024), sessionsession_01B3Kurx8qufrDzNjk4rag7V, branchclaude/issue-8055-automation-register-error-class.Honoring the triage seat's serial constraint: #8054 and #7968 also land in
packages/runtime/src/domains/automation.tsand are held out of this batch. #8055 goes first as the longer pole; the other two are queued behind it and will be dispatched once this lands.Dispatch shape: mirror the sibling
/togglefix (#7535) — the catch arm must classify caller-input refusals as 400VALIDATION_FAILEDwith an ADR-0114details.fields[], not 500INTERNAL_ERROR. Case 4 (the #4277 undeclared-config-key refusal) is the one that matters most: it already produces exactly the self-correcting message #4277 designed, delivered under a status that says the opposite. Case 1's raw zod issue array must stop reaching the wire — the house envelope belongs there.⛔ Not licensed by this card: widening the refusal, softening any of the four refusals into a 200, or changing which bodies are rejected. Only the class and the envelope shape move. A contrast control that a well-formed body still registers 200 is part of the deliverable, not an optional extra.
Generated by Claude Code
{ "issue": 8055, "status": "done", "branch": "claude/issue-8055-automation-register-error-class", "pr": "https://github.com/objectstack-ai/objectstack/pull/8125", "premise_still_valid": true, "summary": "The premise was verified against origin/main and holds exactly as filed: AutomationEngine.registerFlow raises every refusal (FlowSchema.parse, validateControlFlow, validateNodeConfigKeys #4277, validateFlowExpressions) as a plain throw with no .status, the POST / branch in packages/runtime/src/domains/automation.ts did not catch, and dispatch() re-throws non-denials — so the transport's outer catch answered 500 and copied any .issues into details verbatim. The branch now wraps its registerFlow call and converts the engine's verdict through a new module-local flowDefinitionRefusal: 400 VALIDATION_FAILED with an ADR-0114 details.fields[], the raw Zod issue array mapped to fields[] instead of forwarded, and the #4277 self-correcting message preserved verbatim (a 400 never reaches the 5xx sanitiser). An error that declares its own .status/.statusCode still keeps it, and which bodies are refused is unchanged. IMPORTANT DEVIATION FROM THE DISPATCH MODEL, deliberate and argued in the PR: #7535's fix (PR #7558) does NOT transfer as a mechanism — it fixed a 404 not-found with an existence probe BEFORE the service call, over a closed one-condition refusal set. This route's refusals are open (arbitrary Zod parse failures plus two descriptor-driven engine checks) and none is decidable without calling registerFlow, so a pre-check would mean re-implementing the engine's validation in the transport. What carries over, and is why the two routes converge rather than growing a third spelling, is #7558's stated rule that the HTTP class of a plain domain error is the serving boundary's decision, made at the domain handler and not in a generic catch.", "tests": "NEW packages/runtime/src/domains/automation-register-error-class.test.ts (9 tests). Cases 1-3 are produced by PRODUCTION code — the fake's registerFlow calls the real FlowSchema.parse and validateControlFlow from @objectstack/spec/automation, the same calls canonicalizeStoredFlow makes, in the same order; the captured shapes are byte-identical to the issue's ({expected:'string',code:'invalid_type',path:['nodes',0,'label']}; {code:'unrecognized_keys',keys:['next'],path:['nodes',0]}; \"try_catch 'g' try: invalid region — Invalid input: expected object, received array\"). Only #4277 is modelled, from engine.ts's own construction, because that check is descriptor-driven and lives in a package runtime does not depend on. ANTI-VACUITY: no assertion is status!==500 or status>=400; each case asserts status AND error.code AND error.httpStatus AND the presence and per-entry shape of details.fields[], plus its substance — case 1 pins Object.keys(details)===['fields'], details.issues gone, per-entry keys exactly field/code/message, no Zod-internal KEY (\"expected\"/\"received\"/\"path\") anywhere in the serialised body, and nodes.0.label surviving; case 4 pins the #4277 text clause by clause (#4277, unknown config key `totallyBogusKey`, at config.totallyBogusKey, \"not declared by this node type's configSchema\", the \"Declared here: ...\" prescription, node 'n' (notify)) and that the same text reaches the field entry. CONTRAST CONTROLS, green both directions: well-formed body still 200 and still reaches the service; #3899 nameless-definition check still refuses before registerFlow; unknown-flow toggle still 404 RESOURCE_NOT_FOUND. REVERSE VERIFICATION (git checkout, never git stash; restored from a patch file; restore confirmed byte-identical by sha256sum -c with git diff --stat unchanged) — 6 of 9 red, 3 green, and the 3 green are the contrast controls, which is the correct direction for them. Verbatim: ' × case 1 — a node missing `label`: the raw Zod issue array never reaches the wire / ZodError: [' ; ' × case 2 — an unknown node key: located, named, and free of Zod internals / ZodError: [' ; \" × case 3 — a malformed `try_catch` region: the engine's own sentence survives / Error: try_catch 'g' try: invalid region — Invalid input: expected object, received array\" ; \" × case 4 — the #4277 self-correcting text arrives INTACT under the honest status / Error: Flow 'welcome_flow' rejected: 1 undeclared config key(s) (#4277).\" ; ' × no malformed body reaches 500 by any route — no retry-on-5xx client is provoked / ZodError: [' ; ' × an engine error that DECLARES its own class keeps it — the boundary supplies a default, not a verdict / Error: flow store unreachable' ; 'Tests 6 failed | 3 passed (9)'. HONEST NOTE, not dressed to fit the template: the reversal red is 'the promise rejects', not 'the status was 500' — that is the truthful pre-fix behaviour AT THIS SEAM (the handler produced no response at all; the 500 is what the transport's outer catch makes of the escape). The general rule for that escape is already pinned by dispatcher-validation-error.test.ts (#3918, 'leaves a non-validation error on its old path'), so it is cited rather than duplicated. GATES: pnpm --filter @objectstack/runtime test = 143 files / 2185 tests all passing; pnpm --filter @objectstack/runtime typecheck = clean; pnpm check:type-check-debt = OK, runtime TEST_DEBT ceiling 227 held EXACTLY (not raised, and runtime is not among the entries the gate reports as lowerable — the new test file added zero hidden-layer errors); it required a full closure build first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'), which the gate refuses to run without; node scripts/check-nul-bytes.mjs = OK plus a control-character self-scan of every changed file; eslint --no-inline-config on both changed files = clean. CI on PR #8125 was still in_progress at report time (per the draft-PR-time reporting contract).", "open_questions": [], "out_of_scope_findings": [ "filed as #8123: PUT /api/v1/automation/:name makes the identical registerFlow call in the same file and still answers 500 for all four bodies — after this PR merges, POST and PUT disagree about the class of an identical refusal until #8123 lands. Not fixed here because #8055's scope is the POST route; the helper added is module-local and route-agnostic so that fix is one line. Carries a Blocked-by: #8055 line.", "filed as #8124: packages/runtime/src/validation-failure.ts's fieldsFromZodIssues passes Zod's issue codes straight through to fields[].code, which ADR-0114 D2/D3 closed (FieldErrorCode, 27 members; 'unrecognized_keys' is not one). Affects /analytics, /notifications and this route — the pass-through is the package's, not any route's, and the compliant mapper (zodIssuesToFields) is module-local inside @objectstack/rest's rest-server.ts. Filed as one card with three fix options rather than forked locally, which would make automation a third dialect inside one package." ] }
Generated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
Same error-class mismatch #7535 just fixed on the sibling
POST /automation/:name/toggleroute.Symptom
Four distinct malformed registration bodies, all answered 500 INTERNAL_ERROR where 400 is the right class. Reproduced 2×:
label→ 500 carrying a raw zod issue array:{expected:'string', code:'invalid_type', path:['nodes',0,'label']}{code:'unrecognized_keys', keys:['next'], path:['nodes',0]}try_catchregion → 500"try_catch 'g' try: invalid region — Invalid input: expected object, received array"Expected 400
VALIDATION_FAILEDwith an ADR-0114details.fields[], as the sibling/toggleroute now returns and as the descriptor routes return.Contrast controls run in the same pass: a well-formed body still registers 200, and the unknown-flow toggle correctly answers 404
RESOURCE_NOT_FOUND. So the catch arm on this one handler is mapping caller-input refusals toINTERNAL_ERROR.The substance is fine — only the class and the message shape are wrong
The #7545 premise still holds: the refusal is hard and is located. Case 4 in particular produces exactly the self-correcting message #4277 designed. It is delivered under an HTTP status that says the opposite.
Impact — the one #7535 spelled out
An authoring client with retry-on-5xx will retry a request that can never succeed. And an agent authoring a flow reads "the server broke" instead of "your metadata is wrong" — the inverse of the self-correcting shape #4277 chose. Case 1 additionally leaks a raw zod issue array where the house envelope belongs.
Severity
P3.
Source
Found by the platform checklist retest of
automation.flow-node-type-matrix(framework279ee48a), while verifying the #7545 ruling. Held no clause of that item.