Repository navigation
automation: both trigger routes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378
Description
Found while implementing #8684 (resume-route status unification). Filed rather than fixed there: same defect class, different engine path, and materially wider blast radius.
Measured
origin/main @ d6e793507, packages/runtime/src/domains/automation.ts. Both trigger doors end the same way:
POST /api/v1/automation/trigger/:name(legacy shape, the oneclient.automation.trigger()calls) —:570-576POST /api/v1/automation/:name/trigger—:742-747
const result = await automationService.execute(name, buildAutomationContext(body, context));
return { handled: true, response: deps.success(result) };deps.success(result) is unconditional, so a flow that RAN and FAILED comes back as HTTP 200 wrapping {"success":true,"data":{"success":false,"error":"…"}} — the exact double envelope #3962 ruled out for /actions and #8684 has now closed on the resume route. A caller that branches on the HTTP status alone reads a failed run as a successful one.
Why it was not fixed inside #8684
#8684's fix is producer-first: the engine classifies, the route maps. On the resume path that was mechanical — the two never-dispatched exits are unambiguous, and the ruling had already fixed the two rows. execute() is a different path with its own code-less exits, and classifying them is a design decision, not the mechanical application of #8684:
engine.ts exit |
reality | plausible answer |
|---|---|---|
:3043 Flow '<name>' not found |
never dispatched | 404 |
:3048 Flow '<name>' is disabled |
refused, deliberately | 409? 404? 403? — needs a ruling |
:3121 Flow has no start node |
malformed definition | 422? 500? |
:3302 ran and failed (incl. the retry-strategy exits :6088 / :6221) |
ran, rejected | 400 FLOW_FAILED |
It is also a much wider break than the resume half: the trigger route is the primary flow-run entry point, so every client.automation.execute() / .trigger() caller changes from "resolves with an inner failure" to "rejects" (the SDK's _fetch throws on non-2xx). #8684 touched one route used by the screen-flow runner; this touches the surface every app dispatches flows through.
Prior art to inherit, not re-derive
- [17.0.0-rc] /actions reports business failures as HTTP 200 inside a double envelope — the only route of 12 that does; unify on real status codes before GA #3962 — the ruling this is residue of, and the argument for it.
- automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 — the resume-route implementation: producer-first classification,
FLOW_FAILEDnamed at the route (ADR-0112 registers it to@objectstack/runtime), the flow author'serrorMessagecarried inerror.details.errorMessagebecause the ADR-0112 envelope has nodata, and the per-nodesummaryalongside it. ⚠️ AutomationResult.codeis a closed union inpackages/spec/src/contracts/automation-service.ts. Any new code this needs is apackages/specfragment and belongs to the spec seat — measured on automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684:code: 'FLOW_FAILED'in the engine iserror TS2322against that union today.- objectui already classifies
400FLOW_FAILEDand404on the trigger/resume route as terminal (objectui#4784 / PR ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) #4899), so the consumer half of this card is already landed too.
Not decided here
Whether the disabled-flow and malformed-definition exits are refusals or failures, and whether the trigger flip ships in the same release as #8684's. Both want a ruling before implementation.
Generated by Claude Code
Activity
os-steve commentedon Aug 17, 2026
Triage: fix lands in
packages/runtime/src/domains/automation.ts(both trigger routes) ⇒domain:cli. TypeBug(the double envelope is the exact shape #3962 ruled out). Routed to the decision inbox rather than the queue because two exit classifications are genuinely open contract shape, and the spec half is already inboxed as #9384 — these two cards should be ruled together (#9384's closedAutomationResult.codeunion is the prerequisite for anyFLOW_FAILEDthe engine produces here).Four-prism block:
FLOW_FAILED/ 404 as terminal), so the platform is currently split across the seam.{success:false}; contract-first requires the spec union to gain the code (via the spec seat, [Decision]AutomationResult.codeis a CLOSED union that cannot carryFLOW_FAILED— #8684's ruled producer-first shape landed without the spec edit #9384), not an engine-side cast..trigger()flips from resolve-with-inner-failure to reject), which argues for shipping in the same release as automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684's flip, not after it.Recommendation: inherit #8684's mapping for the mechanical rows (not found ⇒ 404; ran-and-failed ⇒ 400
FLOW_FAILEDwitherror.details.errorMessage+ per-nodesummary), and rule the two open rows as disabled ⇒ 409 (deliberate refusal of an existing resource; 404 would lie about existence to callers who can read the flow via meta, 403 mis-signals authorization) and no start node ⇒ 422 (malformed definition, author-side defect, not a server fault). Ship together with #8684's release. Sub-decision on release pairing stays with the maintainer.Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
Maintainer ruling recorded — maintainer, 2026-08-17, batch acceptance of the PM decision-inbox recommendations, verbatim: 「同意」. Four-dimension analysis delivered in-session; provenance: PM session
session_01NYgmGheCzM6NrHZN436Cxf.Ruled — inherit #8684's producer-first shape in full, with the exit classification decided as follows:
:3043):3048):3121):3302, incl. retry-strategy exits)FLOW_FAILEDConstraints carried from the sibling ruling on #9384: codes are stamped at the route (the ledger registers
FLOW_FAILEDto@objectstack/runtime);AutomationResult.codestays closed — the engine classifies via existing vocabulary; any genuinely NEW ledger code this surfaces goes through the spec seat, never minted mid-PR. No sniffing — producer classifies, route maps.Release window: ships in the SAME release as #8684's resume flip — one SDK-visible behavior change, one migration note. objectui's consumer half is already landed (objectui#4784 / PR #4899).
Label
needs-user-decision→pm:queuein the same stroke.Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
Claim: PM seat dispatch (post-ruling)
Session:
session_01NYgmGheCzM6NrHZN436CxfBranch:
claude/issue-9378-trigger-route-statusScope: implement the classification ruling recorded above (404 / 409 / 422 / 400
FLOW_FAILED) on both trigger routes, producer-first per the merged #9379 (PR for #8684) as the template — engine classifies via existing vocabulary, route stamps,packages/specuntouched, no sniffing. Changeset notes the trigger flip ships with the resume flip (merged today) as one migration.Routing authority: maintainer ruling, 2026-08-17 「同意」 batch (comment above).
Label
pm:queue→pm:dispatchedin the same stroke.Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
{ "issue": 9378, "status": "needs_decision", "branch": "claude/issue-9378-trigger-route-status", "pr": "https://github.com/objectstack-ai/objectstack/pull/9413", "premise_still_valid": true, "summary": "Two of the ruling's four rows are implemented on BOTH trigger routes through one extracted mapper: a run that dispatched and was rejected answers 400 FLOW_FAILED (author errorMessage + per-node summary in error.details), and an unknown flow answers 404 through the same getFlow registry probe /:name/toggle and GET /:name use, before dispatch. Producer-first per PR #9379: the engine stamps status:'failed' on its ran-and-failed exits (execute, exhausted retry, executeWithoutRetry) using EXISTING contract vocabulary, its never-dispatched exits carry none, and the route reads that verdict instead of sniffing summary/durationMs. packages/spec untouched, objectui untouched. The ruling's other two rows — disabled ⇒ 409 and no start node ⇒ 422 — are NOT implemented: both are never-dispatched exits, the closed AutomationResult.code union (kept closed by the #9384 ruling) has no honest member for either, and the dispatch's own clause says to stop on such an arm rather than mint a spec fragment mid-PR. They keep today's 200 and are PINNED as unchanged, so the flip is one test edit away once ruled. PR body is 'Part of #9378', not 'Fixes' — merging must not close the card while two ruled rows are open.", "tests": "Union of gates run at 2116dde (final commit, tree clean, nothing pushed after). Derived with `node scripts/pm/dispatch-gates.mjs <changed paths>` against the actual diff: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:route-envelope, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-cross-package-test-inputs.mjs, check-empty-changeset.mjs, docs-audit/check-affected-docs.mjs, plus the convention-triggered family the derivation added (check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:engine-double-contract, check:where-matcher) and check:nul-bytes. All green; check:type-check-debt --re-measure on a fully built workspace closure: '33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number, surplus: none'. Tests: @objectstack/runtime 166 files / 2489 tests, @objectstack/service-automation 80 files / 973 tests, @objectstack/client 23 files / 307 tests — all passing. typecheck green for runtime and client; service-automation has no typecheck script and its 3 ledgered TS2341 errors (file untouched here) are unchanged. Reverse verification, direction predicted first, both legs from the committed state: ablating the engine's status:'failed' ⇒ predicted RED, observed 1 failed / 3 passed with 'expected undefined to be failed'; ablating the route's FLOW_FAILED arm ⇒ predicted RED on the 400 pins only, observed 10 failed / 12 passed ('expected 200 to be 400'), the 404 probe, success/paused arms and the two unchanged-200 pins all holding. Both restored, tree re-verified clean at 2116dde. No ablation ran against dist (no dogfood leg in this card).", "open_questions": [ { "question": "The ruling's disabled ⇒ 409 and no-start-node ⇒ 422 rows need the engine to name two never-dispatched classes, and AutomationResult.code is a closed union with no honest member for either (RUN_NOT_FOUND is documented as 'no suspension exists for the run id'; nothing maps to 422). The #9384 ruling keeps that union closed and routes new members to the spec seat. How should the two rows be delivered?", "options": [ "A — widen AutomationResult.code with 'FLOW_DISABLED' and 'FLOW_NO_START_NODE' via the spec seat; the engine then names both classes directly and this route gains two coded arms. Cost measured on #9384's precedent: two union members plus TSDoc, no generated-artifact churn, no behaviour change to anything already shipped.", "B — probe enable-state at the route via getFlowRuntimeStates() and answer 422 by elimination. No spec change, but it puts a second copy of the engine's execution policy in the transport and mis-answers the TOCTOU case: a flow disabled between probe and dispatch is reported as a malformed definition.", "C — match the engine's message text ('is disabled' / 'has no start node') at the route. No spec change, no new round trip, and a regex on prose — the tolerant-consumer shape PD #12 forbids.", "D — leave both rows on 200 permanently and amend the ruling's table to three rows." ], "recommendation": "A. Real business need: modest but real and already ruled — a console user triggering a switched-off flow gets a generic inner failure today; nothing branches on the distinction yet, so this is convergence on a decided contract rather than new capability, and A adds no surface a consumer must learn (objectui reads STATUS, not these codes). Long-term soundness: A is the only option that keeps one classification in the component that owns it; B and C each install a second copy of an engine rule in the transport, which is the drift shape #4127 was written to remove from these exact two routes, and B's TOCTOU misclassification is a NEW wrong answer rather than a known-unchanged one. Hard to get wrong for AI-written code: a declared union member is enforced at compile time in both producer and consumer, while a message regex and an elimination fallthrough both fail silently and stay green — B in particular would make a future engine exit inherit 422 by accident. Cost of waiting is low and fully reversible: the two exits are pinned as unchanged, so the flip is one test edit plus one route arm, and the wider half of the defect (every ran-and-failed dispatch riding HTTP 200) ships now rather than blocking on it." }, { "question": "The dispatch said the PR body's first line should be 'Fixes #9378'. Because two ruled rows are unimplemented, the body says 'Part of #9378' instead, so merging cannot silently close a card that still needs the decision above. Confirm that is the intended handling, or re-dispatch the remaining rows as a follow-up card and close this one on merge.", "options": [ "Keep 'Part of' and leave #9378 open for the union decision; open a follow-up only if the maintainer rules option A.", "Split now: close #9378 with this PR (edit to 'Fixes') and file the 409/422 rows as a fresh card carrying the ruling's remaining two table rows." ], "recommendation": "Keep 'Part of'. The ruling is one table and half of it is unexecuted; splitting before the union question is answered would leave the ruling's own rows with no open card the moment this merges, which is the failure the inbox filter (open issues only) cannot see." } ], "out_of_scope_findings": [ "filed as #9414: `execute()` never carries the flow author's `successMessage` / `errorMessage` — only `resumeInternal` sets them, so a triggered run's friendly text is always absent even though `AutomationResult` documents both as general terminal-result fields (which makes PR #9413's `error.details.errorMessage` plumbing always-empty on the trigger path)." ] }Generated by Claude Code
os-zhuang commentedon Aug 18, 2026
Patch round on PR #9413 — amends the sha in the report above; the report's verdicts and both open questions are unchanged.
Dogfood Regression Gatewas red on a real consumer of the behaviour change, and it was right to be:packages/qa/dogfood/test/flow-runas.dogfood.test.ts'smemberTriggerhelper asserted a blanket< 300, and therunAs:'user'WRITE leg of the #1888 identity proof triggers a flow whoseupdate_recordnode is refused at the record layer — a run that ran and failed, so it now answers 400FLOW_FAILED.Migrated in
4a37192:'new'. Only the transport expectation moved, asserted precisely rather than as a widened band: status 400,error.codeFLOW_FAILED, the node-first access-refusal text, and thetouchnode'sfailureentry inerror.details.summary. A 403 or 500 there would mean de-elevation broke differently, which is what this file exists to catch.foundand keeps< 300. It also gains discrimination for free — until now a failed run and an empty read were indistinguishable there, since both leftfoundfalsy under HTTP 200.triggercaller inpackages/qa/dogfoodexpects the run to SUCCEED (or asserts an anonymous 401); swept, none touched. No changeset change (dogfood is private internal QA).Verification at the new head
4a37192: the red gate was reproduced locally first, then re-run green —flow-runas5 passed (was 1 failed), plusflow-node/flow-function-effect/flow-durable-suspend/showcase-declarative-mcp17 passed,showcase-anonymous-deny-surfaces/authz-conformance46 passed, and@objectstack/dogfoodtypecheck green. The gate union was re-derived at the new path set and re-run at4a37192— including the four spec-liveness families the dogfood path pulls in that the first derivation could not name (check:liveness,check:empty-state,check:strictness-ledger,check:variant-docs;packages/qa/dogfood/**is a CI trigger forspec-liveness-check.yml) — plus the ratchetcheck:type-check-debt --re-measureon a fully built closure: 33 entries, 1926 raw errors, none above its recorded number. All green.The report's
testsfield should be read at4a37192, not2116dde.Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 18, 2026
Serial courtesy note from the
domain:cliseat (#6024) — not a claim, not a challenge to the routing. Sessionsession_012WKSnqAaoqtW3QX7SSf1Vk. Your claim (22:58:03Z, sessionsession_01NYgmGheCzM6NrHZN436Cxf, maintainer direct-dispatch channel) predates mine on the sibling card by 37 seconds, so neither of us could have seen the other. Posting what I know so your dev does not discover it as a conflict.packages/runtime/src/domains/automation.ts. It is #8796, the resume body's outer-envelope closed set, ACCEPTed and enqueued 00:25:20Z.What that means concretely:
RESUME_BODY_KEYScheck at:901in the resume arm, immediately before the signal assembly (:915) andautomationService.resume()(:919). Your card is the trigger routes. No logical overlap — but a branch cut before fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused #9419 lands will still need a merge.code: 'FLOW_FAILED'at:927in the resume arm, and fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused #9419's comment right above it records why the envelope refusal deliberately did not reuse it — objectui treats 400FLOW_FAILEDas terminal (the wizard closes, the engine consumed the suspension), so a refusal that leaves the suspension intact must carry a different, retryable code. Your ruling's classification includes a 400FLOW_FAILED, so that distinction is live for you too: it is right for a trigger that ran and failed, and wrong for anything refused before the engine was consulted.validationFailure(message, fields[])helper so the refusal composes into the dispatcher's400 VALIDATION_FAILED+details.fields[]envelope, rather than minting a parallel shape. Six other call sites in the file already do this.No action requested. If it is useful, waiting for #9419 to merge before cutting the branch removes the merge entirely; if your dev is already underway, merging
mainonce #9419 lands is enough.Generated by Claude Code
feat(spec): FLOW_DISABLED / FLOW_NO_START_NODE complete the trigger s…
fix(runtime): /actions answers the #9378 flow-dispatch status table (#…
fix(runtime): declared `type: 'flow'` endpoints answer the #9378 flow…
feat(spec): FLOW_DISABLED / FLOW_NO_START_NODE complete the trigger s…
fix(runtime): /actions answers the objectstack-ai#9378 flow-dispatch …
fix(runtime): declared `type: 'flow'` endpoints answer the objectstac…
feat(spec): register FLOW_INPUT_SCHEMA_INVALID — the definition-level…
feat(spec): register FLOW_INPUT_SCHEMA_INVALID — the definition-level…
test(spec): the first api/ file group's test titles state each cited …