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
Activity
- addedbugSomething isn't workingSomething isn't working
on 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:
- Platform long-term coherence — producer-first status classification is already the ruled architecture ([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, ADR-0112, 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 resume implementation). Extending it to the primary trigger door removes the last double-envelope special case rather than adding surface. Open sub-decisions are narrow: HTTP status for a disabled flow and for a malformed (no start node) definition.
- Measured business pull — the trigger route is the entry point every app dispatches flows through; today any caller branching on HTTP status reads a failed run as success. The consumer half is already landed (objectui#4784 / PR ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) #4899 treats 400
FLOW_FAILED/ 404 as terminal), so the platform is currently split across the seam. - AI-agent error-resistance — a loud non-2xx is structurally harder to mis-handle than an inner
{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. - Startup scope discipline — no new capability; convergence on an existing ruling. The real cost is the behavior break for SDK callers (
.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
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:
exit ruling flow not found ( :3043)404 flow disabled ( :3048)409 — the resource exists, the refusal is state-based and retriable after enabling; 403 (not a permission verdict) and 404 (lies about existence) are both rejected no start node ( :3121)422 — malformed definition ran and failed ( :3302, incl. retry-strategy exits)400 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
Claim: PM seat dispatch (post-ruling)
Session:session_01NYgmGheCzM6NrHZN436Cxf
Branch:claude/issue-9378-trigger-route-status
Scope: implement the classification ruling recorded above (404 / 409 / 422 / 400FLOW_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
{ "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
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:- The pinned semantic is untouched and still primary — the run executes AS the member, so the admin's note stays
'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. - The READ leg is deliberately NOT migrated, measured rather than assumed: an RLS-scoped read is FILTERED, not refused, so that run still succeeds with an empty
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. - Every other
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
- The pinned semantic is untouched and still primary — the run executes AS the member, so the admin's note stays
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.⚠️ PR #9419 is in the merge queue right now and edits the same file —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:
- Different region, same file. fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused #9419 adds a
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. - Worth reading before you write, because it is directly on your subject: automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 landed
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. - fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused #9419 also establishes the local convention for refusals in this file: throw the existing
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
- Different region, same file. fix(automation): the resume body's outer envelope becomes a closed set — unknown top-level keys are refused #9419 adds a
- added a commit that references this issue
on Aug 18, 2026 - added 3 commits that reference this issue
on Aug 23, 2026 - added a commit that references this issue
on Aug 26, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Oct 7, 2026
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-747deps.success(result)is unconditional, so a flow that RAN and FAILED comes back asHTTP 200wrapping{"success":true,"data":{"success":false,"error":"…"}}— the exact double envelope #3962 ruled out for/actionsand #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.tsexit:3043Flow '<name>' not found:3048Flow '<name>' is disabled:3121Flow has no start node:3302ran and failed (incl. the retry-strategy exits:6088/:6221)FLOW_FAILEDIt 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_fetchthrows 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
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.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