Skip to content

automation: both trigger routes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378

Description

@os-zhuang

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 one client.automation.trigger() calls) — :570-576
  • POST /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

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

  1. added theissue type on Aug 17, 2026
  2. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Triage: fix lands in packages/runtime/src/domains/automation.ts (both trigger routes) ⇒ domain:cli. Type Bug (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 closed AutomationResult.code union is the prerequisite for any FLOW_FAILED the engine produces here).

    Four-prism block:

    1. 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.
    2. 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.
    3. 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.code is a CLOSED union that cannot carry FLOW_FAILED — #8684's ruled producer-first shape landed without the spec edit #9384), not an engine-side cast.
    4. 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_FAILED with error.details.errorMessage + per-node summary), 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

  3. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    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_FAILED

    Constraints carried from the sibling ruling on #9384: codes are stamped at the route (the ledger registers FLOW_FAILED to @objectstack/runtime); AutomationResult.code stays 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:queue in the same stroke.


    Generated by Claude Code

  4. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    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 / 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/spec untouched, 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:dispatched in the same stroke.


    Generated by Claude Code

  5. self-assigned this
    on Aug 17, 2026
  6. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

  7. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Patch round on PR #9413 — amends the sha in the report above; the report's verdicts and both open questions are unchanged.

    Dogfood Regression Gate was red on a real consumer of the behaviour change, and it was right to be: packages/qa/dogfood/test/flow-runas.dogfood.test.ts's memberTrigger helper asserted a blanket < 300, and the runAs:'user' WRITE leg of the #1888 identity proof triggers a flow whose update_record node is refused at the record layer — a run that ran and failed, so it now answers 400 FLOW_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.code FLOW_FAILED, the node-first access-refusal text, and the touch node's failure entry in error.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 found and keeps < 300. It also gains discrimination for free — until now a failed run and an empty read were indistinguishable there, since both left found falsy under HTTP 200.
    • Every other trigger caller in packages/qa/dogfood expects 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-runas 5 passed (was 1 failed), plus flow-node / flow-function-effect / flow-durable-suspend / showcase-declarative-mcp 17 passed, showcase-anonymous-deny-surfaces / authz-conformance 46 passed, and @objectstack/dogfood typecheck green. The gate union was re-derived at the new path set and re-run at 4a37192 — 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 for spec-liveness-check.yml) — plus the ratchet check:type-check-debt --re-measure on a fully built closure: 33 entries, 1926 raw errors, none above its recorded number. All green.

    The report's tests field should be read at 4a37192, not 2116dde.


    Generated by Claude Code


    Generated by Claude Code

  8. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Serial courtesy note from the domain:cli seat (#6024) — not a claim, not a challenge to the routing. Session session_012WKSnqAaoqtW3QX7SSf1Vk. Your claim (22:58:03Z, session session_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:

    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 main once #9419 lands is enough.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions