Uh oh!
There was an error while loading. Please reload this page.
fix(automation): answer real HTTP status codes on both trigger routes - #9413
Conversation
POST /api/v1/automation/:name/trigger and the legacy
POST /api/v1/automation/trigger/:name both ended `deps.success(result)`
unconditionally, so a flow that ran and failed came back as HTTP 200
wrapping an inner {success:false} — the double envelope #3962 ruled out
for /actions and #8684 closed on the resume route.
Producer first: the engine stamps `status: 'failed'` on the exits that
dispatched a run and were rejected (the same verdict it already writes to
the run log); its never-dispatched exits carry no status. The route reads
that verdict rather than sniffing summary/durationMs, and both doors now
answer through one shared mapper.
- ran and failed (incl. the retry-strategy exit) -> 400 FLOW_FAILED, with
the author's errorMessage and the run summary in error.details
- unknown flow -> 404, through the same registry probe /:name/toggle and
GET /:name use, before anything is dispatched
The ruling's disabled (409) and no-start-node (422) rows are NOT
implemented: both are never-dispatched exits and the closed
AutomationResult.code union has no honest member to tell them apart.
Escalated on the card rather than guessed; they keep today's behaviour
and are pinned as unchanged.📓 Docs Drift CheckThis PR changes 3 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 see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
os-zhuang
commented
Aug 17, 2026
ACCEPT — PM review at The two delivered rows are exactly the ruling's: 404 via the shared existence probe (transport owns existence; engine never asked to run a name it does not hold; The stop on 409/422 is the correct execution of the dispatch's stop-and-report clause, and both rejected workarounds are rejected for the right reasons (message regex = PD #12; route-side enable-state probe = policy duplication + TOCTOU misclassification). The two rows are pinned as unchanged-200 with the flip conditions written in place. Follow-up filed by the PM: the spec-seat card for Flipped ready + armed merge-when-ready — lands through the lane; ships in the same release window as the resume flip per the ruling. Generated by Claude Code |
…_FAILED
The `runAs:'user'` WRITE leg triggers a flow whose `update_record` node is
refused at the record layer, so the run fails — and since the trigger route
answers real HTTP status codes it answers 400 FLOW_FAILED instead of 200
wrapping an inner {success:false}. `memberTrigger`'s blanket `< 300` was
written against the old contract and is the only dogfood consumer this
change touches.
The semantic the file pins is unchanged and still primary: the run executes
AS the member, so the admin's note stays 'new'. What moves is the transport
expectation, and it is asserted precisely rather than as a widened band —
status 400, error.code FLOW_FAILED, the node-first access-refusal text, and
the `touch` node's failure entry in error.details.summary. A 403 or 500 here
would mean de-elevation broke differently and must not pass.
The READ leg is deliberately NOT migrated: an RLS-scoped read is FILTERED,
not refused, so that run still succeeds with an empty `found` and keeps its
`< 300` expectation. Measured locally, not assumed. That leg also gains
discrimination for free: until now a failed run and an empty read were
indistinguishable there, because both left `found` falsy under HTTP 200.
Verified locally on a built workspace closure:
vitest run test/flow-runas.dogfood.test.ts → 5 passed (was 1 failed)
flow-node · flow-function-effect · flow-durable-suspend ·
showcase-declarative-mcp → 17 passed
showcase-anonymous-deny-surfaces · authz-conformance → 46 passed
pnpm --filter @objectstack/dogfood typecheck → green
Every other dogfood trigger caller expects the run to SUCCEED (or asserts an
anonymous 401), so none is touched by this contract. No changeset change:
@objectstack/dogfood is private internal QA and the wire change is already
documented in .changeset/automation-trigger-status-unification.md.Uh oh!
There was an error while loading. Please reload this page.
…tatus ruling (#9415) The #9378 ruling classified four trigger-route outcomes. PR #9413 delivered two of them (404, and 400 FLOW_FAILED) and stopped on the other two exactly as dispatched: a DISABLED flow and one with NO START NODE are both exits that never dispatched anything, and the closed `AutomationResult.code` union had no honest member for either. The two workarounds were measured and rejected — a regex on the engine's message prose, and a route-side enable-state probe that duplicates execution policy and opens a TOCTOU window answering "disabled mid-flight" as "malformed definition". This is the spec-seat fragment the #9384 ruling routes here: a deliberate union widening with measured need, not a call-site mint. - spec: `AutomationResult.code` gains 'FLOW_DISABLED' / 'FLOW_NO_START_NODE', documented as trigger-time refusals classified BEFORE dispatch and grouped apart from the resume-refusal members. Both registered in the ADR-0112 error-code ledger under @objectstack/runtime — the door that names the wire vocabulary, following FLOW_FAILED. - engine: the disabled-flow and no-start-node exits stamp their codes and continue to carry NO `status`. That absence is #9413's provable separation of never-dispatched from ran-and-failed, and the test asserting it is strengthened rather than relaxed. - route: two arms in `respondToFlowTrigger` read the producer codes — FLOW_DISABLED => 409, FLOW_NO_START_NODE => 422. No sniffing of `summary`, `durationMs` or the message text. - tests: the two "stay unchanged at 200" pins, written to flip when this landed, now pin 409/422; both directions are covered (a classified exit is answered by its code even carrying a failed run's incidental fields; an UNCLASSIFIED exit still stays 200). Client-side pins mirror the FLOW_FAILED ones. - docs: the five pages the #9413 drift advisory named, re-verified against the now-complete four-row contract. Also corrects two stale TSDoc bullets on the same union that named 'PERMISSION_DENIED' / 'INVALID_SIGNAL' by lowercase spellings the union has never contained and no producer emits (engine stamps the SCREAMING_SNAKE members; ADR-0112 D6). Fixes#9378
… messages (#9414) `AutomationResult` declares `successMessage` / `errorMessage` as a general terminal-result feature, but `resumeInternal` was the only producer. A flow dispatched through `POST /api/v1/automation/:name/trigger` carried neither, so the author's own text reached a caller only when the run happened to pause and be resumed — and the console consumer that reads `error.details.errorMessage` (#9413, objectui `flowResponse.ts`) got nothing on every non-screen flow. Four terminal exits now produce the pair: `execute()`'s success and failure returns, both of `executeWithoutRetry`'s, and `retryExecution`'s exhausted exit — a different exit from `execute()`'s own, reached by exactly the runs that failed repeatedly. Paused, skipped and never-dispatched exits keep carrying neither, and that boundary is pinned rather than only described. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
Part of #9378 —
Part of, notFixes, deliberately: two of the four rows the rulingdecided are NOT implemented here and the card must stay open for them. Which two, and
why, is the second section below.
Both automation
triggerdoors answeredHTTP 200wrapping an inner{success: false}for a flow that ran and failed:
That is the double envelope #3962 ruled out for
/actionsand #8684 closed on the resumeroute (PR #9379, merged 2026-08-17). This is the wider half of the same residue: the
trigger route is the door every app dispatches flows through, so a caller that branches on
the HTTP status alone read every failed run as a successful one.
Implemented per the maintainer ruling recorded on the card (2026-08-17), inheriting
#9379's shape in full: producer classifies, route maps,
packages/specuntouched, nosniffing.
What changes
Producer first —
packages/services/service-automation/src/engine.ts.execute()stamps
status: 'failed'on the exits where a run DISPATCHED and was rejected — theordinary failure exit, the exhausted
errorHandling.strategy: 'retry'exit, andexecuteWithoutRetry's catch. It is the same lifecycle verdict the exit already writes tothe run log two statements earlier. Its three never-dispatched exits (flow not found, flow
disabled, no start node) carry no
status, and that absence is what makes the transport'sarm provable rather than a heuristic.
statusand not a newcode:AutomationResult.codeis a closed union of resume-refusalmembers, and the ruling on #9384 (2026-08-17) keeps it closed — the engine classifies with
EXISTING vocabulary.
status?: 'completed' | 'paused' | 'failed'is exactly that:declared on the contract, documented as the run's lifecycle verdict. No
packages/specedit, measured —
tsc --noEmitonservice-automationholds at its 3 ledgered TS2341errors, unchanged.
Route stays a pure mapper —
packages/runtime/src/domains/automation.ts. Both doorsanswer through one extracted
respondToFlowTrigger, the way they already shared onecontext builder (#4127) so they could not drift about what a body means.
404(registry probe, before dispatch)400FLOW_FAILEDThe 404 is the shared existence probe, not a result reading. It is the same
getFlowprobePOST /:name/toggle(#7535) andGET /:nameuse, so no two doors candisagree about which flows exist, and the engine is never asked to run a name it does not
hold. The line this draws is principled and worth a reviewer's eye: existence is a
question the transport owns (every REST resource route owns its own 404); execution
policy is the producer's.
getFlowis optional onIAutomationService, so animplementation that omits it is unchanged rather than 404'd on a guess.
The consumer contract is carried, not dropped. The ADR-0112 envelope has no
data, sothe flow author's own
errorMessagetravels inerror.details.errorMessage— the onedocumented location the console reads (objectui
flowResponse.ts, PR #4899) — and therun's per-node
summaryrides indetailsfor the same reason it was on the 200 body: itis how a caller finds WHICH node failed. objectui already classifies
400FLOW_FAILEDand
404on these routes as terminal (objectui#4784 / PR #4899); nothing there ischanged by this PR.
@objectstack/clientneeded no code change and this is a measured finding, not anomission:
fetchalready throws on every non-2xx before any unwrapping, soclient.automation.trigger(),client.automation.execute()andclient.project(id).automation.execute()now reject witherr.code === 'FLOW_FAILED',err.httpStatusanderr.details.errorMessageinstead of resolving with an inner failure.All three surfaces document the new contract and all are pinned by tests —
trigger()reads
res.json()whileexecute()readsunwrapResponse(), so both unwrap paths arepinned separately.
The two rows this does NOT implement, and why they are reported rather than resolved
The ruling's table has four rows. Two of them — flow disabled ⇒ 409 and no start
node ⇒ 422 — cannot be delivered honestly under the constraints the same ruling carries,
and the dispatch's own instruction for that case is to stop on the arm and report back.
Both are never-dispatched exits, so separating them needs the producer to say which is
which, and the closed
AutomationResult.codeunion has no honest member for either:RUN_NOT_FOUNDis documented as "no suspension exists for the run id",RESUME_IN_PROGRESSis a concurrent-resume refusal, and nothing in it maps to 422 at all. Widening the union is
a
packages/specfragment the ruling on #9384 routes to the spec seat, and it explicitlyforbids minting one mid-PR.
The two workarounds were considered and rejected, not overlooked:
"is disabled"/"has no start node") — a regexon prose, the tolerant-consumer shape PD Add comprehensive test suite for Zod schema validation #12 forbids.
getFlowRuntimeStates()— this would put asecond copy of the engine's own execution policy in the transport, and it cannot answer
422 except by elimination, which mis-answers the TOCTOU case: a flow disabled between
probe and dispatch would be reported as a malformed definition. Wrong for a new reason
is not better than unchanged.
So those two exits keep TODAY's behaviour, and both are PINNED as unchanged in
automation-trigger-route-status.test.tsrather than left unexamined. Pinning them is alsowhat proves the 400 arm is not a shape heuristic: both carry
success: falseexactly likea failed run — one of them carrying
summaryanddurationMstoo — and both stay 200because they carry no
status: 'failed'. When the union question is ruled, those are theassertions that flip.
For the spec seat, one line: adding
'FLOW_DISABLED'and'FLOW_NO_START_NODE'tothat union would let the engine name both classes directly and this route gain two more
coded arms. Same measured cost as #9384 recorded for
FLOW_FAILED: union members plusTSDoc, no generated-artifact churn.
Consumer migration — the dogfood gate found a real one (second commit)
Dogfood Regression Gatewent red onpackages/qa/dogfood/test/flow-runas.dogfood.test.ts, and it was right to: therunAs:'user'WRITE leg of the #1888 identity proof triggers a flow whoseupdate_recordnode is refused at the record layer, so the run fails and now answers
400FLOW_FAILEDinstead of a 200 wrapping the inner failure. Its
memberTriggerhelper asserted a blanket< 300.The semantic that file pins is unchanged and still primary — the run executes AS the
member, so the admin's note stays
'new'. Only the transport expectation moved, and it isasserted precisely rather than as a widened band: status
400,error.codeFLOW_FAILED, the node-first access-refusal text, and thetouchnode'sfailureentryin
error.details.summary. A403or500there would mean de-elevation broke in adifferent way, and this file exists to catch exactly that.
The READ leg is deliberately not migrated — measured, not assumed: an RLS-scoped read
is FILTERED rather than refused, so that run still succeeds with an empty
foundand keepsits
< 300expectation. That leg also gains discrimination for free: until now a failedrun and an empty read were indistinguishable there, because both left
foundfalsy underHTTP 200.
Every other
triggercaller inpackages/qa/dogfoodexpects the run to SUCCEED (orasserts an anonymous 401), so none is touched by this contract — swept rather than assumed,
and all of them were run locally (below). No changeset change:
@objectstack/dogfoodisprivate internal QA and the wire change is already documented in the changeset.
Scope held
packages/specuntouched, per the ruling.cited, not changed.
content/docs/releases/untouched; the changeset is this PR's only release input.domain-handler-registry.test.tsgained a resolvinggetFlowstub:their subject is body translation and contract-method preference, and a registry that
claims the flow does not exist never reaches
executenow. Disposition per fixturetriage: declarations added, no assertion weakened.
Verification
Union of gates re-run at
4a37192— the final commit, tree clean, nothing pushed after it.Re-derived against the actual changed paths, which is what caught the four spec-liveness
families the dogfood path pulls in (
packages/qa/dogfood/**is a CI trigger forspec-liveness-check.yml, because the liveness ledger reads dogfood usage as evidence):All green at
4a37192, pluscheck:nul-bytes.check:type-check-debt --re-measureran ona fully built workspace closure: 33 ledger entries, 1926 raw errors, none above its
recorded number, surplus none.
check:where-matcher: 253 matchers, 0 silently wrong.check:liveness: every governed-type property classified, README state table current.check-adr-0087-registrationanswerednot-required (no-migration-prescription)— thisretires no metadata surface, so
objectstack migrate metahas nothing to rewrite; thechangeset is the channel that reaches these consumers.
check-changeset-no-majorrefused amajorfor the same reason #9379 recorded (one Changesets fixed group ⇒ a single majorpromotes the monorepo), so the bump is
minorwith the BREAKING framing kept in the body.Tests:
@objectstack/runtime166 files / 2489 tests,@objectstack/service-automation80 files / 973 tests,
@objectstack/client23 files / 307 tests — all passing.typecheckgreen forruntime,clientanddogfood;service-automationhas notypecheckscript and its 3 ledgered TS2341 errors (in a file this PR does not touch) areunchanged.
Dogfood, run locally on a built workspace closure (the red gate reproduced first, then
fixed):
Reverse verification, direction predicted before running, both legs from the committed
state:
status: 'failed'on the ran-and-failed exit ⇒ predicted RED, oneengine test:
expected undefined to be 'failed'. Observed exactly that (1 failed, 3passed).
FLOW_FAILEDarm ⇒ predicted RED on the 400 assertions only, GREENon the 404 probe, the success/paused arms and the two unchanged-200 pins. Observed 10
failed / 12 passed, the failures being
expected 200 to be 400and the twodetailsreads — i.e. the 200 pins held while the 400 pins fell, which is the shape that proves
they are pinning different facts.
Both ablations restored with a checkout of the committed path, and the tree re-verified
clean.
Generated by Claude Code