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:

constdetails: Record<string,unknown>={
...(!validation&&e?.code&&typeofe.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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions