Skip to content

feat(spec): FLOW_FAILED carries the refused write's inner refusal — AutomationResult.userMessage / innerRefusal and one FlowFailureDetailsSchema for the four doors - #25037

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24996-flow-failed-inner-refusal
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24996-flow-failed-inner-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #24996

Clause-②: yes (widening)

The contract half of ruling B′ on #24914: the maintainer's 「同意」 in decision batch #379, item 5. With it, the refusal a flow write node meets reaches the client structurally, the way a record save's refusal does. This PR changes only packages/spec, plus one runtime docblock (named below). Emission is #24914's job: the crud nodes, the engine carriage and the four doors. The console half is an objectui card. Until those land, nothing on the wire changes.

The ruling's bullets (verbatim from #24996, "What the ruling binds")

What changed

  • AutomationResult (packages/spec/src/contracts/automation-service.ts) gains two optional members:
    • userMessage?: string, with exactly ApiErrorSchema.userMessage's semantics: presence IS the marking, it is set only when the inner refusal's producer opted in, and it never replaces error;
    • innerRefusal?: FlowInnerRefusal.
  • TriggerFlowResponseSchema.data mirrors both members. The compile-time pin TriggerFlowResponse['data'] = AutomationResult (Two packages/spec route response schemas declare a data NARROWER than the contract their route relays — AnalyticsResultResponseSchema and TriggerFlowResponseSchema #13078) holds.
  • FlowInnerRefusalSchema (new, @objectstack/spec/api) is { code, message }, with code: ErrorCode. ErrorCode is the same closed ADR-0112 set that ApiErrorSchema.code parses against, so "wire spelling" is enforced, not just described: UNIQUE_VIOLATION parses, and a driver spelling such as ER_DUP_ENTRY is refused.
  • FlowFailureDetailsSchema (new) is the error.details of every door's 400 FLOW_FAILED arm, declared once: errorMessage?, summary? and innerRefusal?. It is a plain non-strict object, the ResumeFailureDetailsSchema pattern. On the resume door, runId / status / repairable sit beside these keys in the same object, and each schema parses its own members.
  • The A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 ceiling. On /actions, error.details was capped at "exactly two keys … no new schema keys". The ruling relaxes that cap by one named key: the declared set is now the three keys above, at all four doors. The ceiling is enforced in runtime, not in spec:
  • Unchanged: error.message at every door, error.code: FLOW_FAILED, and AutomationResult.code (still the closed union, no new ledger code). No wording pin moves.
  • Regenerated with repo tooling (check:generated --fix, which rewrote only what it proved stale): api-surface/, export-origins/, declaration-map/, authorable-surface/, json-schema.manifest/, content/docs/references/**, and the strictness-ledger counts (api/ 446 → 448, the two new object sites).

The member's name: innerRefusal

  • It uses the ruling's own words ("the inner refusal").
  • It fits the vocabulary that already exists around flow failures, where "refusal" is the word for a classified no: FlowActionRefusal, classifyFlowRefusal, FlowRefusalCode, refusal: true on ApiErrorSchema.
  • The inner prefix keeps it distinct from refusalMessage, which belongs to an end node's deliberate outcome: 'refused', a different thing.
  • Rejected alternatives:
    • cause: it collides with Error.cause chaining semantics and says nothing about the shape;
    • writeRefusal: too narrow, since the same { code, message } shape fits a read node refused by permission;
    • error: already the diagnostic string on AutomationResult.

Contract review owed

This PR owes a CONTRACT_REVIEW_TIER contract review: Clause-②: yes (widening), a new public schema pair, and a ruled relaxation of the #9585 ceiling. Two choices are left to the reviewer explicitly:

  1. innerRefusal.code is the closed ErrorCode, not an open string. A producer must map to the wire spelling, and an app-specific hook code has no declaredCode arm inside the member.
  2. FlowFailureDetailsSchema declares the two existing artefacts (errorMessage, summary) beside the new key, so the ceiling is a declared set instead of prose. Before this PR, those artefacts existed on the wire but were not declared in spec.

Tests

  • packages/spec/src/api/automation-api.zod.test.ts, new suite FlowFailureDetailsSchema — one inner-refusal shape across the four FLOW_FAILED doors:
    • for each of the four doors (trigger, resume, /actions, endpoint), an envelope whose details.innerRefusal is { code: 'UNIQUE_VIOLATION', message } and which has a top-level userMessage validates through BaseResponseSchema and FlowFailureDetailsSchema. On the resume door it also validates through ResumeFailureDetailsSchema;
    • the same four envelopes without either member still validate;
    • the declared key set is exactly the three ruled keys;
    • an unregistered inner code is refused at ['code'];
    • both members of the inner refusal are required;
    • the trigger response data accepts the mirrored members.
  • A type-level pin, FlowFailureInnerRefusalIsTheContracts, holds FlowFailureDetails['innerRefusal'] equal to AutomationResult['innerRefusal'].

Local results, all at 22786c992e (git rev-parse --short HEAD, the final commit):

  • pnpm --filter @objectstack/spec test: 703 files / 21382 passed, 1 todo.
  • pnpm --filter @objectstack/spec test:repo: 72 files / 1236 passed.
  • pnpm --filter @objectstack/spec typecheck: exit 0. That covers tsc --noEmit, the scripts typecheck and the test-typecheck ratchet, which compiles the new type-level pin.
  • pnpm --filter @objectstack/spec check:generated --fix: 5 artifacts were proven stale and regenerated, then re-checked green.
  • The runtime ceiling pin, actions-flow-dispatch-status.test.ts: 19 passed, unchanged, as expected.
  • runtime tsc --noEmit: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands derived 113 commands, plus the 6 roster families that sit under a touched directory. 118 of these 119 are exit 0. A first pass printed PREREQUISITE NOT MET for six of them; those were re-run green after the dependency closure was built. check:dual-build-cjs-loads is NOT MEASURED: it needs a full workspace build and is left to CI's Build Core.
  • pnpm lint (repo-wide eslint) was not run locally and is CI's.

Acceptance notes

  • Premise correction. The issue places AutomationResult in packages/spec/src/automation/flow.zod.ts :1085. Those lines are FlowSchema's authored successMessage / errorMessage, which are flow-definition keys. AutomationResult is the TypeScript interface in packages/spec/src/contracts/automation-service.ts, and its Zod mirror is TriggerFlowResponseSchema.data. The members land there, and flow.zod.ts is untouched: a runtime-produced userMessage is not something a flow author writes. The claim's file surface named flow.zod.ts, so contracts/automation-service.ts (and the one runtime docblock) are additions to that surface, declared here.
  • pnpm --filter @objectstack/runtime typecheck is exit 1 locally, in its check:test-typecheck half only. It reports one type error in src/organization-ownership-boot-guard.turso-remote.kernel.test.ts, a file this PR does not touch. This PR's runtime diff is a docblock-only edit, so it cannot move a type verdict. The cause is unverified: possibly a stale dependency dist on this shared box, possibly drift already on main. CI's TypeScript Type Check is the authority on it.
  • contract.zod.ts is untouched: the A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 ceiling does not live there. ApiErrorSchema.userMessage already carries the envelope's top-level channel.

Generated by Claude Code

…utomationResult.userMessage/innerRefusal and FlowFailureDetailsSchema

Ruling B' on #24914 (decision batch #379 item 5).

Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
Co-authored-by: Claude <noreply@anthropic.com>
…ck relaxed by ruling B'

Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
Co-authored-by: Claude <noreply@anthropic.com>
… reference docs and strictness counts

Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 12 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/runtime/src/action-execution.ts, packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via AutomationResult (symbol, a top-level interface), FlowFailureDetails (symbol, a top-level type))
  • content/docs/api/declarative-endpoints.mdx (via /api/v1/automation/:name/trigger (route, a path literal in a comment on a changed line; bridged from symbol TriggerFlowResponseSchema — its route source's handler names it))
  • content/docs/automation/flows.mdx (via AutomationResult (symbol, a top-level interface), /api/v1/automation/:name/trigger (route, a path literal in a comment on a changed line; bridged from symbol TriggerFlowResponseSchema — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via userMessage (symbol, a field of interface AutomationResult))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/automation/:name/trigger (route, a path literal in a comment on a changed line; bridged from symbol TriggerFlowResponseSchema — its route source's handler names it))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via AutomationResult (symbol, a top-level interface), userMessage (symbol, a field of interface AutomationResult))
  • content/docs/releases/v17/17-3.mdx (via AutomationResult (symbol, a top-level interface))
  • content/docs/releases/v17/17-4.mdx (via userMessage (symbol, a field of interface AutomationResult))
  • content/docs/releases/v17/17-6.mdx (via AutomationResult (symbol, a top-level interface))
  • content/docs/releases/v17/index.mdx (via userMessage (symbol, a field of interface AutomationResult))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/runtime/src/action-execution.ts, packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 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 3b3830ca223eaea7875babb155e47eebbcd0300f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6bf75f27ef21ad1b6f7bb571ec9ceda5de665fd6 — the merge of head 22786c992e928af6c43ca807998408adb52add4e into base 3b3830ca223eaea7875babb155e47eebbcd0300f, 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 6bf75f27ef21ad1b6f7bb571ec9ceda5de665fd6 && git checkout 6bf75f27ef21ad1b6f7bb571ec9ceda5de665fd6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3b3830ca223eaea7875babb155e47eebbcd0300f 22786c992e928af6c43ca807998408adb52add4e && git checkout -B drift-repro 3b3830ca223eaea7875babb155e47eebbcd0300f && git merge --no-ff 22786c992e928af6c43ca807998408adb52add4e

node scripts/docs-audit/affected-docs.mjs --json 3b3830ca223eaea7875babb155e47eebbcd0300f

⚠️ 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 3b3830ca223eaea7875babb155e47eebbcd0300f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 22786c992e928af6c43ca807998408adb52add4e
Local-runs: none
Scope: packages/spec/src/api/automation-api.zod.ts, packages/spec/src/contracts/automation-service.ts + packages/spec/src/api/contract.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/actions-flow-dispatch-status.test.ts, packages/types/src/thrown-http-error.ts, packages/spec/src/api/automation-api.zod.test.ts

① Derived judgments

Read against card #24996 (ruling B′ on #24914, decision 6297041774, batch #379 item 5), #9378 5321043983, #9585 5339708379, and the net three-dot diff of 22786c992e against main (13 files).

  1. AutomationResult gains userMessage? and innerRefusal? — packages/spec/src/contracts/automation-service.ts:551-580. The card locates AutomationResult at automation/flow.zod.ts:1085; those lines are FlowSchema's authored successMessage/errorMessage (flow-definition keys, not the run result). The dev's premise correction is right: the interface the ruling names lives in contracts/automation-service.ts, and its Zod mirror is TriggerFlowResponseSchema.data. flow.zod.ts is untouched, which is the correct reading (a runtime-produced userMessage is not an authorable key). The userMessage docblock binds to ApiErrorSchema.userMessage semantics (contract.zod.ts: presence is the marking, producer opt-in at throw time, "never replaces" the diagnostic channel) — faithful to bullet 1.
  2. TriggerFlowResponseSchema.data mirrors both (automation-api.zod.ts:428-436); the compile-time parity pin TriggerFlowDataMatchesContract (Two packages/spec route response schemas declare a data NARROWER than the contract their route relays — AnalyticsResultResponseSchema and TriggerFlowResponseSchema #13078) is untouched and still has to hold, and a second pin FlowFailureInnerRefusalIsTheContracts ties FlowFailureDetails['innerRefusal'] to AutomationResult['innerRefusal']. Declared once, not twice: the interface imports FlowInnerRefusal (type-only) from the api module, so the two cannot drift.
  3. FlowInnerRefusalSchema — code is the closed ErrorCode. Checked at the head: ErrorCode (error-code-ledger.zod.ts:1634, z.enum) is exactly what ApiErrorSchema.code parses against (contract.zod.ts:29), and UNIQUE_VIOLATION is a member (ledger rows at :1069, :1378, :1457). So "wire spelling" is enforced, not just described: the member accepts what a record save answers in error.code and refuses a driver spelling (ER_DUP_ENTRY, tested at ['code']). This IS the ruling's wire spelling — error.code on the wire is always a closed member since [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; an app-specific spelling rides declaredCode, never error.code. Reviewer choice 1 accepted. Consequence for A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914 (flagged below, not a defect here): the member has no declaredCode arm, so a hook refusal with an unregistered code must be demoted by the emitter with the same demotedDeclaredCode read the doors already use, and the app's own spelling is not carried inside the member. The ruling fixed the shape to { code, message }, so that is within the ruling, not a loss the spec seat could have avoided without re-ruling.
  4. FlowFailureDetailsSchema declared once for the four doors (automation-api.zod.ts:584-600): errorMessage?, summary? (FlowRunSummarySchema), innerRefusal?. Plain non-strict z.object, the same shape class as ResumeFailureDetailsSchema (:503), so on the resume door runId/status/repairable parse beside these keys from one details object; the test proves both schemas accept each other's members. Bullet 2 is met: one declaration, four doors. Note the spec has no per-door binding of details schemas (the doors live in runtime), so "declared once" is the schema plus the test's four-door matrix; emission is A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914's, as the card says.
  5. AutomationResult.code unchanged and closed — no edit to the union, no new ledger row (error-code-ledger.zod.ts not in the diff). automation: both trigger routes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378 holds. error.message wording is not touched anywhere; the test matrix uses each door's own wording verbatim.
  6. The A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 ceiling split. The ruling relaxes "exactly two keys, no new schema keys" on /actions by one named key. In this PR the relaxation is (a) the declared three-key set (pinned by Object.keys(FlowFailureDetailsSchema.shape) = ['errorMessage','innerRefusal','summary']), (b) the FlowActionRunDetails docblock in packages/runtime/src/action-execution.ts:1046-1056 now citing B′ (comment only; the interface still has two fields), while (c) the exact-equality negative pin actions-flow-dispatch-status.test.ts:377 (toEqual({ errorMessage, summary })) is deliberately left. This is a faithful split: the pin asserts what the wire carries today, and nothing emits innerRefusal until A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914; relaxing the pin now would weaken a negative pin with no positive emission to replace it, which the ruling does not ask of the spec seat. The split is explicit in the PR body and the dev report, and A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914's claim must own the pin change (flag below). Reviewer choice 2 (declaring errorMessage/summary beside the new key, so the ceiling is a declared set) accepted: it is what "declare that member ONCE … so the four doors emit one shape" requires, and those two keys already rode the wire undeclared.
  7. Generated artefacts are consistent with the two new exports: api-surface, export-origins, declaration-map, authorable-surface, json-schema.manifest, content/docs/references/{api/automation-api.mdx,index.mdx} (1542 → 1544, api 438 → 440), strictness ledger api/ 446 → 448 (two new non-strict object sites). Counts agree with each other.
  8. Tests (automation-api.zod.test.ts:547-650): positive and negative legs for each of the four doors through BaseResponseSchema + FlowFailureDetailsSchema (+ ResumeFailureDetailsSchema on resume), optional-absent leg, three-key pin, closed-code refusal, both-members-required, trigger-data mirror. Matches the card's "what done looks like".
  9. Check-runs on the head at review time (40 runs): 30 completed, all success or skipped; none red. Still in_progress: Test Core 1-6, Build Core, Type Check · workspace, Type Check · debt ledger, Type Check · consumer gates. Green already: Type Check · source gates, every Lint job incl. Lint & Repo Gates, Build Docs, Check Changeset, Governed Surface Queue Guard, Spec property liveness. The dev's local red on runtime check:test-typecheck (untouched organization-ownership-boot-guard.turso-remote.kernel.test.ts) is not reproduced by any red run on this head so far; this PR's runtime diff is a docblock, so it cannot move that verdict. No red run ⇒ no FAIL on this axis; the merge-group guard reads the final state.

② Semver level

@objectstack/spec minor, Clause-②: yes (widening) — correct. Two new exported schemas and types, two new optional members on an exported interface and its Zod mirror, every new member optional; an envelope without them parses as before. Nothing narrowed: no enum member removed, no key made required, no message wording moved. The changeset (.changeset/24996-flow-failed-inner-refusal.md) names the ruling, the relaxed ceiling and the unchanged channels.

③ Boundary flags

Implemented-by: claude/issue-24996-flow-failed-inner-refusal
Reviewed-by: session_01D6F9axfoPzR7t8G1J7Ytzz

VERDICT: PASS

Seat re-read of the check-runs at landing: all 42 runs on 22786c992e have completed: 36 succeeded, the rest were skipped, and none is red.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 03:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 03:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 045858f Oct 21, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24996-flow-failed-inner-refusal branch October 21, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants