Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): answer 400 VALIDATION_FAILED, not 500, for a malformed flow registration (#8055) - #8125
Conversation
…flow registration (#8055) `POST /api/v1/automation` answered 500 INTERNAL_ERROR for four distinct malformed flow definitions — a node missing `label`, an unknown node key, a malformed ADR-0031 `try_catch` region, and a config key the node type's descriptor does not declare (#4277). Every refusal was correct; only the class and the envelope were wrong. The registration branch now catches the engine's verdict and serves it as 400 VALIDATION_FAILED with an ADR-0114 `details.fields[]`. A raw Zod issue array no longer reaches the wire, and the #4277 self-correcting message survives verbatim (a 400 never reaches the 5xx message sanitiser). Which bodies are refused is unchanged. An engine error that declares its own `.status` / `.statusCode` keeps it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8055
POST /api/v1/automationanswered 500INTERNAL_ERRORfor four distinct malformed flow definitions. It now answers 400VALIDATION_FAILEDwith an ADR-0114details.fields[].Every one of those refusals was already correct — the definition really is bad, and the engine really does locate the fault. The class was the defect, and the class is what clients branch on: 5xx means "the server broke, try again", 4xx means "your request was wrong, do not". Case 4 is the sharpest: #4277 shaped that message so an authoring agent can self-correct — it names the key, the node, the node type, and the keys the descriptor does declare — and it arrived under a status telling the agent to retry unchanged.
Cause
AutomationEngine.registerFlowraises every one of its refusals as a plain throw with no.status:labelFlowSchema.parsedetails.issues= the raw Zod issue arrayFlowSchema.parse{code:'unrecognized_keys', keys:['next'], path:['nodes',0]}try_catchregionvalidateControlFlowtry_catch 'g' try: invalid region — ...validateNodeConfigKeys(#4277)The registration branch did not catch, so the throw left
handleAutomationRequest— anddispatch()re-throws everything that is not a permission denial. The transport's outer catch (dispatcher-plugin'serrorResponseBase) then did what it does with a statusless throw: 500, with any.issuescopied intodetailsverbatim. That last part is why case 1 leaked Zod's internal shape onto a wire position the house envelope owns.Fix
packages/runtime/src/domains/automation.ts— thePOST /branch wraps itsregisterFlowcall and converts the engine's verdict through a new module-localflowDefinitionRefusal.Four deliberate choices worth reviewing:
ZodError, or an engine message matched by its prose.POST /api/v1/automation/:name/toggleanswers 500 INTERNAL_ERROR instead of 404 NOT_FOUND for an unknown flow #7535's fix rejected exactly that ("teaching a shared catch to recognise one engine's message string would make every domain's not-found depend on that prose"), and here it is also wrong on the merits:registerFlowis the parse of a caller-supplied document, so "the definition is bad" is the honest default for a refusal it raises — not a guess about which one it raised..status/.statusCodeis passed through untouched, which is the same precedenceerrorFromThrownalready applies. Nothing in the engine declares one today, so this is not a live branch — it is the seam that keeps a future engine-side "the flow store is unreachable" (a real 503) from being answered as the author's fault. Pinned by a test.resumebranch a few routes down already uses for engine-originated refusals, and the same shape/meta's save routes use for a service that throws on caller metadata.dispatch()re-throws non-denials, so a transport calling it directly would otherwise get an exception where every other refusal on this domain hands back a response. It also means the long 3b — wire the flow executors toparse()their config, and tighten the undeclared-key warning into an error #4277 message is never handed to a 5xx path.fields[]and no.issues, so there is nothing left forerrorFromThrownto copy.Unchanged: which bodies are refused. A definition that registered before still registers (contrast control below), every one that was refused is still refused, and the engine's own message survives verbatim — a 400 never reaches the #3867 5xx sanitiser. The #3899 body checks still refuse a nameless definition before the engine is asked.
On the model this was asked to follow
The card asked me to follow #7535's fix on the sibling
/toggleroute and to say so if the shape did not transfer. It transfers as a principle and not as a mechanism, so this is a deliberate adaptation rather than a copy:POST /api/v1/automation/:name/toggleanswers 500 INTERNAL_ERROR instead of 404 NOT_FOUND for an unknown flow #7535 (PR fix(runtime): answer 404, not 500, when toggling an unknown automation flow (#7535) #7558) fixed a 404 not-found by running an existence probe before touching the service. Its refusal set is closed — one condition, one lookup, decided ahead of the call.registerFlow. A pre-check here would mean re-implementing the engine's validation in the transport, which is the drift fix(runtime): answer 404, not 500, when toggling an unknown automation flow (#7535) #7558 chose the shared probe to avoid.What does carry over, and is the reason the two routes now converge rather than growing a third spelling, is #7558's stated rule: which HTTP status a plain domain error means is the serving boundary's decision, made at the domain handler and not in a generic catch. Both routes now make it there, in the house envelope, naming the thing that went wrong.
On
fields[].codeFor a refusal with no Zod path to point at (cases 3 and 4), the entry addresses the body root — the convention
fieldsFromZodIssuesalready uses — withcode: 'invalid_value', the ADR-0114 catalog's "rejected for a reason no other member names". Re-deriving a path by parsing the engine's sentence would be the same prose dependency rejected above.For the Zod cases the per-issue
codeis whateverfieldsFromZodIssuesproduces, which today is Zod's own vocabulary rather than the ADR-0114 D3 catalog (unrecognized_keysis not aFieldErrorCodemember). That pass-through is the package's, not this route's —/analyticsand/notificationsemit through the same helper — and the compliant mapper lives module-local inside@objectstack/rest'srest-server.ts. Forking a local copy here would make automation a third dialect inside one package, so it is filed as #8124 with the three routes and the three fix options, not actioned as a rider on a P3 bug fix.Tests
New:
packages/runtime/src/domains/automation-register-error-class.test.ts(9 tests).The fake service runs the real refusals for three of the four cases —
FlowSchema.parseandvalidateControlFlowimported from@objectstack/spec/automationare the very callscanonicalizeStoredFlowmakes, in that order — so cases 1 to 3 are produced by production code. Only #4277 is modelled, from the engine's own construction, because that check is descriptor-driven and lives in a package@objectstack/runtimedoes not depend on. The captured shapes are byte-identical to the ones the issue reports, includingtry_catch 'g' try: invalid region — Invalid input: expected object, received array.Anti-vacuity: no test asserts
status !== 500orstatus >= 400. Every case asserts the full envelope —status,error.code,error.httpStatus, and the presence and per-entry shape ofdetails.fields[]— plus its own substance:Object.keys(details)is exactly['fields']), thatdetails.issuesis gone, that each entry has exactlyfield/code/message, that no Zod-internal key ("expected","received","path") appears anywhere in the serialised response, and that the located faultnodes.0.labelsurvives."keys"is gone.parse()their config, and tighten the undeclared-key warning into an error #4277 text clause by clause:#4277,unknown config key `totallyBogusKey`,at config.totallyBogusKey, "not declared by this node type's configSchema", theDeclared here: ...prescription, andnode 'n' (notify)— and that the same text reaches the field entry rather than a stub.Contrast controls, all green before and after: a well-formed body still registers 200 and still reaches the service with the definition unchanged; the #3899 nameless-definition check still refuses before
registerFlowis called; the unknown-flow toggle still answers 404RESOURCE_NOT_FOUND(#7535).Reverse verification
Fix removed with
git checkout(nevergit stash), suite re-run, fix restored from a patch file, and the restore confirmed byte-identical bysha256sum -cwithgit diff --statunchanged.6 of 9 red, 3 green — and the 3 that stayed green are the contrast controls, which is the correct direction for them. Verbatim:
Reported honestly rather than 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, and the 500 the issue observed is what the transport's outer catch makes of that escape. The general rule for that escape (a statusless throw carrying
.issuesbecomes a 500 whosedetails.issuesis the raw array) is already pinned one directory up bydispatcher-validation-error.test.ts(#3918, "leaves a non-validation error on its old path"), so it is cited rather than duplicated. A returned response, by contrast, is what the plugin serves verbatim — which is why these tests readresult.response.Verification
pnpm --filter @objectstack/runtime test— 143 files, 2185 tests, all passingpnpm --filter @objectstack/runtime typecheck— cleanpnpm check:type-check-debt— OK, not raised (@objectstack/runtimeTEST_DEBT ceiling 227 held exactly; re-measured against a fully built closure, since the gate refuses to measure without one)node scripts/check-nul-bytes.mjs— OK, plus a control-character self-scan of every changed fileeslint --no-inline-configon both changed files — cleanOut-of-scope findings, filed not fixed
PUT /api/v1/automation/:nameanswers 500 for the same four malformed flow bodies #8055 just reclassified on POST #8123 —PUT /api/v1/automation/:namemakes the identicalregisterFlowcall in the same file and still answers 500 for all four bodies. Left out becausePOST /api/v1/automationanswers 500 INTERNAL_ERROR for a malformed flow body — including the #4277 undeclared-config-key refusal the #7545 ruling leans on #8055's scope is the POST route, and because leaving it visible is better than a silent second-route change; the helper added here is module-local and route-agnostic so that fix is one line. Worth noting: after this merges, POST and PUT disagree about the class of an identical refusal untilPUT /api/v1/automation/:nameanswers 500 for the same four malformed flow bodies #8055 just reclassified on POST #8123 lands.fieldsFromZodIssuesleaks Zod's issue codes onto the wire'sfields[].code— the exact pass-through ADR-0114 D3 closed, still live in@objectstack/runtime#8124 — thefieldsFromZodIssues/ ADR-0114 D3 pass-through described above.No
content/docs/releases/**edits. Changeset included (@objectstack/runtimepatch).Generated by Claude Code