Repository navigation
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
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:
error.details.errorMessage— the flow AUTHOR's own failure text (flow.errorMessage). This is the single place the console reads it from (objectuiflowResponse.ts, PR ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) #4899), and bug(service-automation):execute()never carries the flow author'ssuccessMessage/errorMessage— onlyresume()does, so a triggered run's friendly text is silently dropped #9414 put it there deliberately because the envelope has nodata.error.details.summary— the run's per-node accounting, i.e. WHICH node failed.
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
detailsstraight todeps.error(message, status, details); /actionsTHROWS (dispatchFlowAction,packages/runtime/src/action-execution.ts) and the route serves the throw throughHttpDispatcher.errorFromThrown, which delegates toresolveThrownHttpError(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) inresolveThrownHttpError. One place, both doors, but it widens what any thrower anywhere can put on a wire — and thecodelimb right above it was deliberately narrowed in [Decision] The dispatcher'serror.codehas 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
dispatchFlowActiona typed refusal carrier the/actionsroute 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
os-support-ai commentedon Aug 18, 2026
Triage: routed
domain:cli(the decision surface ispackages/types/src/thrown-http-error.ts+packages/runtime/src/action-execution.ts) · type Feature (any code fix widens what the/actionsenvelope carries) →needs-user-decision(public contract shape — manual floor).Premises (re-check before ruling):
resolveThrownHttpErrorstill buildsdetailsfrom a closed list and drops a thrown.details—grep -n "const details" packages/types/src/thrown-http-error.tserrorMessage/summaryviaerror.details—git grep -n "errorMessage" packages/runtime/src --include='*.ts'400 FLOW_FAILEDpost-The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446 — re-run that card's paired-door check.Four-facet analysis:
error.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106) narrow — a typed refusal carrier recognised at the flow-action door is a closed, named channel; Option A is a general "any throw can ship wire payload" widening, the exact thing [Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106 narrowedcodeto prevent. Option C freezes a declared-but-undelivered author message at the documented record-page door.error.details.errorMessage(objectuiflowResponse.ts, bug(service-automation):execute()never carries the flow author'ssuccessMessage/errorMessage— onlyresume()does, so a triggered run's friendly text is silently dropped #9414 / objectui#4899), and dispatching a flow through an ACTION is the documented way to expose it on a record page; today the author's failure text is silently replaced by the raw engine error.Recommendation: B — a typed refusal carrier that
dispatchFlowActionthrows and the/actionsroute recognises before the generic catch, carrying exactlyerrorMessage+summary;resolveThrownHttpErroruntouched. If ruled C instead, the deliverable becomes docs + the objectui reader stating the asymmetry, not code.Generated by Claude Code
huangyiirene commentedon Aug 19, 2026
Claim: PM loop round 25⚠️ See the hard boundary below —
Session:
session_01WeN7F6jQFpcqW2BN56RdPaBranch:
claude/issue-9585-flow-action-refusal-carrierWorktree:
objectstack-issue-9585Domain:
domain:cliFile surface:
packages/runtime/src/action-execution.ts(dispatchFlowAction— throw the typed carrier) andpackages/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_TIERinscripts/pm/dispatch-gates.mjs);--tierreturns no path-derived mandate, ⛔ not recalled — the mandate is Clause ②Clause-②: yes — the
/actionsenvelope starts carrying two fields it never carried. Native type is Feature and triage's own routing note says "any code fix widens what the/actionsenvelope carries"Serial constraints cleared:
action-execution.tsanddomains/actions.ts— no in-flight PR names either.dispatcher-plugin.tsis held by sibling #9813. #9832 (packages/cli/src/commands/serve.ts) is disjointThe 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.
dispatchFlowActionthrows it; the/actionsdoor recognises it before the generic catch and carries exactly two fields intoerror.detailsof the ADR-0112 envelope:errorMessage(the flow author's own failure text) andsummary(the per-node run accounting).resolveThrownHttpErrorstays UNTOUCHED.packages/types/src/thrown-http-error.tsis out of surface. The shared resolver's closeddetailslist (The direct-mount REST package door answers 500 INTERNAL_ERROR for coded 4xx errors the dispatcher twin maps correctly #8016 / [Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106) remains the rule for every other thrower, and Option A — a general "any throw may declare wire payload" widening — was rejected by name as the exact thing [Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106 narrowedcodeto prevent.error.details.errorMessage(objectuiflowResponse.ts, bug(service-automation):execute()never carries the flow author'ssuccessMessage/errorMessage— onlyresume()does, so a triggered run's friendly text is silently dropped #9414 / objectui#4899), and dispatching a flow through an ACTION is the documented record-page path.400 FLOW_FAILEDpost-The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446.⛔ Hard boundary — the file the
/actionsroute is registered in is HELD/actionsroute registration lives atpackages/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 insidedispatcher-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
resolveThrownHttpErrorstill buildsdetailsfrom a closed list and drops a thrown.details—grep -n "const details" packages/types/src/thrown-http-error.tserrorMessage/summaryviaerror.details—git grep -n "errorMessage" packages/runtime/src --include='*.ts'400 FLOW_FAILEDpost-The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446 — re-run that card's paired-door checkpackages/runtimehas 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
resolveThrownHttpError.errorMessageandsummaryare 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/actionsside would not notice them drifting apart again.Gates
Re-derive with⚠️ Three confirmed instances (#9721 / #9700) of it naming neither ⚠️
node scripts/pm/dispatch-gates.mjsfrom the actually changed paths.pnpm lintnorcheck:slot-lookup— runpnpm lintregardless.check:dispatcher-error-vocabularyis 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-reportmarker 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
feat(runtime): carry the flow author's errorMessage and run summary t…
chore: changeset for the #9585 flow-action refusal carrier
huangyiirene commentedon Aug 19, 2026
{ "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
feat(runtime): carry the flow author's errorMessage and run summary t…
fix(packages): a protocol-incompatible install answers 422 with its s…