Skip to content

feat(runtime): carry the flow author's errorMessage and run summary through the /actions door via a typed refusal carrier (#9585) - #9950

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-9585-flow-action-refusal-carrier
Aug 19, 2026
Merged

feat(runtime): carry the flow author's errorMessage and run summary through the /actions door via a typed refusal carrier (#9585)#9950
huangyiirene merged 2 commits into
mainfrom
claude/issue-9585-flow-action-refusal-carrier

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#9585

What

A flow dispatched through POST /api/v1/actions/:object/:action (a type: 'flow' action — the documented record-page path) that ran and failed answered 400 FLOW_FAILED with only the raw engine error. The trigger door additionally ships two fields in error.details of the ADR-0112 envelope: errorMessage (the flow author's own failure text — the single field objectui flowResponse.ts reads) and summary (the per-node run accounting). At the action door the author's text was declared-but-never-delivered.

Maintainer ruling (2026-08-19, Option B, recorded on the issue — verbatim authorization 「接受你的所有建议。」): a typed refusal carrier that dispatchFlowAction throws and the /actions route recognises before the generic catch, carrying exactly those two fields; the shared resolver stays untouched.

How

The ruling's boundaries, kept mechanically

Tests

Six new pins in actions-flow-dispatch-status.test.ts, the paired-door file from #9446 — including the drift pin the card asked for: one failed run driven through BOTH doors, error.details compared door-against-door (anchored once so two undefineds cannot satisfy it). Also pinned: absent artefacts invent none at either door; a never-dispatched refusal carrying incidental run fields ships them at NEITHER door; brand-based recognition rejects a field-shape lookalike.

Reverse verification from the committed state: reverting only the recognition branch (domains/actions.ts to origin/main) reddened exactly the 2 predicted pins (the /actions details pin and the drift pin) while all 13 #9446 pins stayed green — the carrier degrades to yesterday's answer, never a different one. Restored: 19/19.

At 6a5ac829a5 (this head): targeted suite 19/19; full @objectstack/runtime suite 176 files / 2634 tests green; pnpm --filter @objectstack/runtime typecheck green. Gates (readings, not exit codes): pnpm lint clean; check:dispatcher-error-vocabulary "OK — 17 unregistered code-stamping site(s), all classified" (the carrier stamps only the registered FLOW_FAILED, no new site); check:route-envelope 11 modules / 8 conformant / 0 ratcheted / 3 exempt; check:engine-double-contract OK 321 pinned; check:where-matcher / check:query-options-erasure baselines verified, no files added; check:type-check-coverage OK 64/77; check:type-check-debt --re-measure "none above its recorded number"; changeset gates (adr-0087 / no-major / empty / self-tests), check:cross-package-test-inputs, check:slot-lookup, check:nul-bytes, check-affected-docs all green. Gate set derived by node scripts/pm/dispatch-gates.mjs (no args, script-derived change set).

Changeset

@objectstack/runtime minor — additive envelope payload at one door; not breaking, so no ADR-0087 disposition marker is required (check:adr-0087-registration green).


Generated by Claude Code

…hrough the /actions door via a typed refusal carrier (#9585)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 10 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via errorMessage (symbol))
  • content/docs/protocol/objectui/actions.mdx(via errorMessage (symbol))
  • content/docs/ui/actions.mdx(via errorMessage (symbol))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/flow-dispatch-status.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 301bf26be117bd59d0b31f594a433f2a4ecfd6f0packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3877a6b1bbf6ffa9f5c40004ba62706ef4309a4f — the merge of head 6a5ac829a58b90b5e4f967c0fd7b0fb87c474f68 into base 301bf26be117bd59d0b31f594a433f2a4ecfd6f0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3877a6b1bbf6ffa9f5c40004ba62706ef4309a4f && git checkout 3877a6b1bbf6ffa9f5c40004ba62706ef4309a4f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 301bf26be117bd59d0b31f594a433f2a4ecfd6f0 6a5ac829a58b90b5e4f967c0fd7b0fb87c474f68 && git checkout -B drift-repro 301bf26be117bd59d0b31f594a433f2a4ecfd6f0 && git merge --no-ff 6a5ac829a58b90b5e4f967c0fd7b0fb87c474f68
node scripts/docs-audit/affected-docs.mjs --json 301bf26be117bd59d0b31f594a433f2a4ecfd6f0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 301bf26be117bd59d0b31f594a433f2a4ecfd6f0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT

Reviewed against the maintainer's Option B ruling on #9585 and the dispatch brief (claim comment 5339708379). Report received GitHub-first as instructed (#9585 comment 5340371701, os-dev-report marker present). 6 files, no governed path (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) ⇒ no governed-PR fork.

The ruling's three hard constraints — all held, and I checked each on origin/main rather than reading the report

  1. resolveThrownHttpError untouched.packages/types/src/thrown-http-error.ts is not in the changed-file set. ⭐ Stronger than the ruling asked: the suite pins the resolver closedexpect(resolved.details).toBeUndefined() on a thrown carrier. Option A was rejected by name; this makes a future attempt at it redden a test instead of landing quietly. That is the right shape for a rejected fork.
  2. Recognition ahead of the generic catch, without restructuring it. The new try wraps only the dispatchFlowAction call; every non-carrier throw err rethrows into the untouched generic catch. One recognised shape, one door.
  3. Bounded to errorMessage + summary.FlowActionRunDetails declares exactly those two optional keys, and the JSDoc names a third key as out of scope by construction.

"The trigger door's own exit" is a measured claim, not a rhetorical one

I did not take this on the report's word. packages/runtime/src/domains/automation.ts:587-590 builds its 400-arm details as:

const runDetails = refusal.code === 'FLOW_FAILED'
? { ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}) }

The carrier's construction in action-execution.ts is the same conditional-spread, same guard, same source fields — including the refusal.code === 'FLOW_FAILED' gate, which is why the never-dispatched rows invent nothing. This is fidelity to an existing door, not a parallel re-implementation that will drift.

The drift pin does the one thing a door-agreement pin usually gets wrong

action.details.errorMessage === trigger.details.errorMessage would be satisfied by both doors shipping undefined — the exact way this kind of pin rots into a tautology. It is anchored: expect(trigger.…errorMessage).toBe(AUTHOR_MESSAGE). Both-absent can no longer pass. The message is deliberately excluded from the equality with a stated reason (this door names the flow; the trigger door's URL already does).

Two suspicions I raised and overturned by measuring — recorded because a rejected objection is a reading too

  • ⚠️"The MCP bridge has no call site, so the ruling's one-door boundedness may not hold as claimed."False, and my own filter caused it. I swept callers with git grep -n dispatchFlowAction | grep -v "action-execution.ts:" — written to drop the definition, it also dropped the caller in the same file. The real population is 2, exactly as reported: domains/actions.ts:357 (recognises) and action-execution.ts:1105 inside invokeBusinessAction, reached from domains/mcp.ts:649. MCP calls dispatchFlowActiondirectly, not through handleActionsRequest, so it genuinely does not get the recognition branch. Boundedness holds. Lesson worth keeping: a filter written to exclude a definition also excludes same-file callers.
  • ⚠️"let result: any is a typing regression" — the old binding was const result = await …, and actionExec is a fully-typed import * as. False.dispatchFlowAction is declared ): Promise< any > (action-execution.ts:611-622), so the old const was alreadyany. The annotation makes explicit what was already true and loses nothing. I was one step from sending a rework for this; the declared signature is what settled it.

Surface

action-execution.ts is not on @objectstack/runtime's public surfacepackage.jsonexports declares only "."dist/index.d.ts, and src/index.ts re-exports neither action-execution nor flow-dispatch-status. So the newly exported FlowActionRefusal / isFlowActionRefusal widen no package API. Clause ②'s widening here is on the wire only (error.details gains two keys at one door), which is precisely where the ruling put it.

Two files beyond the declared surface — both comment-only, both obligatory

flow-dispatch-status.ts (+12/-5) and http-dispatcher.test.ts (+4/-2) are pure prose. Both previously asserted the divergence this card closes — flow-dispatch-status.ts said the errorMessage/summary details "do NOT ride this door", and http-dispatcher.test.ts said "the live /actions producer does exactly that". Landing the fix while leaving those sentences standing would have put two false statements into the repo. Not a breach; correcting them was the right call and I would have asked for it otherwise.

Boundary

One comment over-claim — noted, no change requested

The carrier's JSDoc says a brand check means "no script handler can impersonate the flow door's channel by throwing a lookalike." The brand is Symbol.for, i.e. globally registered — in-process code that knows the string can reproduce it, so the stated reason is not the operative one. The conclusion still holds, for a different reason: isFlowActionRefusal is consulted only inside the actionType === 'flow' branch's try, which a script handler's throw never enters. Recording the correct reasoning rather than asking for an edit.

Clause ② and the enqueue gate

Clause ② = yes (declared at claim time; the /actions envelope starts carrying two fields it never carried). The card was dispatched at the contract-review tier (CONTRACT_REVIEW_TIER, scripts/pm/dispatch-gates.mjs:1628 = claude-fable-5) per the claim comment, so the gate is satisfied by the dispatch.

⚠️ Stating a limit honestly: the dispatch tier is not mechanically re-derivable from the PR — nothing in the diff, the checks, or the PR metadata records it, so this reading rests on the pre-work claim comment as the audit record. That is a real gap in the Clause ② gate rather than a problem with this PR, and I am filing it separately.

⏳ Landing

Build Coresuccess · Temporal Conformancesuccess · TypeScript Type Check, Lint & Repo Gates, Test Core (3 shards), Dogfood Regression Gate (3 shards) in_progress. in_progress is not a pass — the PR stays draft; flip-ready then enqueue on convergence of all six required checks, each read from its own conclusion.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 19, 2026 10:14
@huangyiirene
huangyiirene added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 6aceca9Aug 19, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-9585-flow-action-refusal-carrier branch August 19, 2026 10:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@huangyiirene@claude