Skip to content

A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585

Description

@os-zhuang

Found while implementing #9446 (PR #9584). Filed unassigned — not fixed there: closing it needs a decision about a shared resolver's contract, which is wider than that card's ruling.

What is missing

When a flow that ran and failed is reported by the trigger door, the ADR-0112 envelope carries two things beside the message:

The same flow, dispatched through POST /api/v1/actions/:object/:action as a type: 'flow' action, answers neither. After #9446 both doors agree on the status and the code (400 FLOW_FAILED), so the divergence that is left is exactly the payload beside them.

Why, mechanically

The two doors reach the envelope differently:

  • the trigger door RETURNS a built response and passes details straight to deps.error(message, status, details);
  • /actions THROWS (dispatchFlowAction, packages/runtime/src/action-execution.ts) and the route serves the throw through HttpDispatcher.errorFromThrown, which delegates to resolveThrownHttpError (packages/types/src/thrown-http-error.ts).

resolveThrownHttpError does not read a thrown .details. It BUILDS one, from a closed list:

const details: Record<string, unknown> = {
  ...(!validation && e?.code && typeof e.code !== 'string' ? { code: e.code } : {}),
  ...(issues ? { issues } : {}),
  ...(validation ? { fields: validation.fields } : {}),
};

So errorMessage and summary stamped on the thrown error would be dropped silently. Stamping them anyway is the shape that reads as fixed and is not.

Why it matters

A metadata app that dispatches its flow through an ACTION (the documented way to expose one on a record page) shows the caller the raw engine error instead of the message its author wrote for exactly this case. The author's text is declared, and at this door it is declared-but-never-delivered — the declared ≠ enforced shape, in the payload rather than in the schema.

The decision this needs

resolveThrownHttpError is shared by two doors (the dispatcher and packages/rest's direct-mount registrar), and #8016 moved the rule there precisely so one door cannot hold its own copy. So widening what a throw may declare is a change to that shared contract, not a local fix:

  • A — let a throw declare structured details (an allow-list of keys, or verbatim) in resolveThrownHttpError. One place, both doors, but it widens what any thrower anywhere can put on a wire — and the code limb right above it was deliberately narrowed in [Decision] The dispatcher's error.code has a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106 for that reason.
  • B — give dispatchFlowAction a typed refusal carrier the /actions route recognises before the generic catch, leaving the shared resolver untouched. Keeps the widening out of the shared rule, at the cost of a second path into the envelope at one door.
  • C — rule that the run artefacts are a property of the TRIGGER route (the door that owns runs) and that the action door is not expected to carry them. Then the docs and objectui's reader should say so, and this closes as declared-by-design rather than as residue.

No option is obviously right, which is why this is filed rather than decided: the same reasoning that made #9446 a ruling ("is this a property of the contract or of one route?") applies here to the payload rather than to the status.

Not caused by #9446

This gap predates it — /actions has never carried these fields. What #9446 changes is that it is now the only remaining difference between the two doors' answers, so it is worth naming instead of leaving inside a larger divergence.


Generated by Claude Code

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage: routed domain:cli (the decision surface is packages/types/src/thrown-http-error.ts + packages/runtime/src/action-execution.ts) · type Feature (any code fix widens what the /actions envelope carries) → needs-user-decision (public contract shape — manual floor).

    Premises (re-check before ruling):

    Four-facet analysis:

    Recommendation: B — a typed refusal carrier that dispatchFlowAction throws and the /actions route recognises before the generic catch, carrying exactly errorMessage + summary; resolveThrownHttpError untouched. If ruled C instead, the deliverable becomes docs + the objectui reader stating the asymmetry, not code.


    Generated by Claude Code

  2. huangyiirene commented on Aug 19, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 25
    Session: session_01WeN7F6jQFpcqW2BN56RdPa
    Branch: claude/issue-9585-flow-action-refusal-carrier
    Worktree: objectstack-issue-9585
    Domain: domain:cli
    File surface: packages/runtime/src/action-execution.ts (dispatchFlowAction — throw the typed carrier) and packages/runtime/src/domains/actions.ts (recognise it before the generic catch), plus pins and .changeset/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: contract-review tier attempted first (CONTRACT_REVIEW_TIER in scripts/pm/dispatch-gates.mjs); --tier returns no path-derived mandate, ⛔ not recalled — the mandate is Clause ②
    Clause-②: yes — the /actions envelope starts carrying two fields it never carried. Native type is Feature and triage's own routing note says "any code fix widens what the /actions envelope carries"
    Serial constraints cleared: action-execution.ts and domains/actions.ts — no in-flight PR names either. ⚠️ See the hard boundary below — dispatcher-plugin.ts is held by sibling #9813. #9832 (packages/cli/src/commands/serve.ts) is disjoint

    The ruling — ⛔ not re-litigable

    Maintainer ruling recorded 2026-08-19, verbatim authorization 「接受你的所有建议。」 (adopting the presented recommendation across an 11-card decision-inbox review):

    Option B — a typed refusal carrier. dispatchFlowAction throws it; the /actions door recognises it before the generic catch and carries exactly two fields into error.details of the ADR-0112 envelope: errorMessage (the flow author's own failure text) and summary (the per-node run accounting).

    ⛔ Hard boundary — the file the /actions route is registered in is HELD

    ⚠️ The /actions route registration lives at packages/runtime/src/dispatcher-plugin.ts:1385 (server!.post(\${base}/actions//:action`, …)`), and that file is held by sibling card #9813 this round (same-file hard serial, maintainer-reaffirmed 2026-08-19).

    ⇒ The ruled fix should land in action-execution.ts + domains/actions.ts — the throw and the handler-side recognition — not in the route registration. If you find the recognition genuinely has to happen inside dispatcher-plugin.ts, STOP and report. ⛔ Do not edit it: sequencing the two cards is my call, not a widening you may take.

    Premise re-check before writing — triage's own commands

    ⚠️ The card was written 2026-08-18 and packages/runtime has moved several times since (#9746, #9848 both landed in it today). ⛔ Treat every line reference as stale; locate by text.

    PM mechanism assumptions — measure; falsifying them is a good run

    • That a typed carrier can be recognised at the door before the generic catch without restructuring the catch. If the only way in is through the shared resolver, that contradicts the ruling — stop and report the fork, ⛔ do not quietly widen resolveThrownHttpError.
    • That errorMessage and summary are available at the throw site with the same shape the trigger door ships. ⭐ Pin the two doors against each other — the card's whole point is that they diverge, so a test that only checks the /actions side would not notice them drifting apart again.

    Gates

    Re-derive with node scripts/pm/dispatch-gates.mjs from the actually changed paths. ⚠️ Three confirmed instances (#9721 / #9700) of it naming neither pnpm lint nor check:slot-lookup — run pnpm lint regardless. ⚠️ check:dispatcher-error-vocabulary is very likely implicated here — run it and report the reading. ⛔ Never weaken a gate.

    Read verdicts from reported counts, ⛔ never a piped exit code. Report at draft-PR time. ⚠️ Post the report as an issue comment on this card with the os-dev-report marker first, and read it back — the terminal channel dropped two reports this shift and I wrongly treated both devs as dead.


    Generated by Claude Code

  3. huangyiirene commented on Aug 19, 2026

    @huangyiirene
    Collaborator
    {
      "issue": 9585,
      "status": "done",
      "branch": "claude/issue-9585-flow-action-refusal-carrier",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9950",
      "premise_still_valid": true,
      "summary": "Implemented the ruled Option B: dispatchFlowAction (packages/runtime/src/action-execution.ts) throws a typed FlowActionRefusal carrier on the classified ran-and-failed row, stamping status/code/message identically to the plain throw it replaces plus runDetails read off the engine result exactly as the trigger door reads them; the /actions handler (packages/runtime/src/domains/actions.ts) recognises the Symbol.for-branded carrier in a try/catch scoped to the flow dispatch — ahead of the generic catch, which is unchanged — and answers deps.error(message, status, {code, ...runDetails}), the trigger door's own exit. Both PM mechanism assumptions held: recognition needed no catch restructuring and no shared-resolver path (resolveThrownHttpError untouched, pinned to still drop the carrier's payload), and errorMessage/summary are available at the throw site in the trigger door's exact shape (AutomationResult.errorMessage/.summary). dispatcher-plugin.ts (held by #9813) untouched; never-dispatched rows and the unclassified residual keep plain throws (no invented run evidence, mirroring the trigger door); MCP run_action degrades to yesterday's exact answer by construction and by pin.",
      "tests": "All at head 6a5ac829a5 (working tree byte-identical throughout the union; the final commit only recorded the changeset file). NEW: 6 pins in actions-flow-dispatch-status.test.ts incl. the door-against-door drift pin (one failed run through BOTH doors, error.details compared door-to-door, anchored once). pnpm --filter @objectstack/runtime exec vitest run src/actions-flow-dispatch-status.test.ts → 19/19 passed. Full package: pnpm --filter @objectstack/runtime test → 'Test Files 176 passed (176), Tests 2634 passed (2634)'. typecheck (tsc --noEmit, script name echoed) → exit 0. REVERSE VERIFICATION from committed state: git restore --source=origin/main domains/actions.ts → predicted direction observed exactly: 'Tests 2 failed | 17 passed' (the /actions details pin + the drift pin red; all 13 #9446 pins green — carrier degrades to the previous answer); restored → 19/19. No ablation rebuild owed: edited files are same-package src, resolved by vitest from src, never through a dependency's exports/dist. GATES (readings, not exit codes; set derived by node scripts/pm/dispatch-gates.mjs with no args, script-derived change set incl. untracked): pnpm lint → eslint clean; check:dispatcher-error-vocabulary → 'OK — 17 unregistered code-stamping site(s), all classified; scope: 1844 non-test source files' (carrier stamps only registered FLOW_FAILED — no new site); check:route-envelope → 11 modules, 8 conformant/0 ratcheted/3 exempt; check:engine-double-contract → OK 321 pinned/133 DEBT/2 exempt; check:where-matcher + check:query-options-erasure → 'baseline key set verified against 45862a5: no files added'; check:type-check-coverage → OK 64/77; check:type-check-debt --re-measure (closure built via turbo, 70 tasks) → '33 ledger entries re-measured, none above its recorded number'; changeset-gate-self-tests, objectui-changeset, adr-0087-registration, changeset-no-major, empty-changeset, cross-package-test-inputs (33 self-tests, 12 declared packages), slot-lookup, nul-bytes, check-affected-docs → all green. Ratchet families re-run at 6a5ac829a5 after the final commit → all green. PREMISE SWEEP (populations): grep 'const details' packages/types/src/thrown-http-error.ts → 1 hit (line 194), closed 3-key list intact, drops thrown .details; git grep errorMessage over packages/runtime/src → trigger door ships via error.details (domains/automation.ts 400 arm, 2 sites) — both premises held; #9446 paired-door suite re-run green (both doors still 400 FLOW_FAILED). CONSUMER SWEEP of dispatchFlowAction: population 2 call sites (domains/actions.ts flow branch — recognises; MCP invokeBusinessAction — fallback pinned). No downstream package consumes action-execution.ts (not exported from runtime index or any subpath), so no consumer typecheck sweep owed in either filter direction.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions