Skip to content

Commit 045858f

Browse files
feat(spec): FLOW_FAILED carries the refused write's inner refusal — AutomationResult.userMessage / innerRefusal and one FlowFailureDetailsSchema for the four doors (#25037)
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") > - `AutomationResult` (`packages/spec/src/automation/flow.zod.ts`, beside `successMessage` / `errorMessage` :1085–1086) gains an optional `userMessage` with `ApiErrorSchema`'s semantics (`packages/spec/src/api/contract.zod.ts:50-97`: producer-marked end-user text that "never replaces message") and an optional inner-refusal member of shape `{ code, message }`, `code` in its wire spelling. The member's name is the spec seat's; its shape is ruled. > - The `FLOW_FAILED` details of the trigger, resume, `/actions` and endpoint doors declare that member ONCE (the `ResumeFailureDetailsSchema` pattern, `packages/spec/src/api/automation-api.zod.ts:430`), so the four doors emit one shape. `message` is unchanged everywhere: no wording pin moves. > - `AutomationResult.code` stays a closed union (#9378 ruling `5321043983`): no new ledger code; the inner code rides inside the member. > - ⛔ Not this card: the crud-node and engine changes (#24914), the doors' emission (#24914, cli cross-lane), the console (objectui), the unique sentence's object API name (#24951, engine). ## 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` (#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 #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: - the `FlowActionRunDetails` docblock (`packages/runtime/src/action-execution.ts`), which this PR updates to cite ruling B′ (comment only, no type change); - the exact-equality negative pin in `packages/runtime/src/actions-flow-dispatch-status.test.ts`. That pin is **left unchanged on purpose**: nothing emits `innerRefusal` yet, so it still describes the wire exactly. #24914 relaxes it in the same change that emits the key. - **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 #9585 ceiling does not live there. `ApiErrorSchema.userMessage` already carries the envelope's top-level channel. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50169dc commit 045858f

13 files changed

Lines changed: 739 additions & 11 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a failed flow run can carry the refusal its write node met — `AutomationResult.userMessage` / `AutomationResult.innerRefusal`, and one `FlowFailureDetailsSchema` for every door's `400 FLOW_FAILED` details
6+
7+
Clause-②: yes (widening)
8+
9+
The maintainer's 「同意」 on ruling B′ of #24914 (decision batch #379 item 5): the inner refusal of a refused flow write node reaches the client structurally, the way a record save's refusal does, and its code takes the wire spelling. This release is the contract half. The engine and the four doors start emitting the new members when the services and runtime half (#24914) lands; until then nothing on the wire changes.
10+
11+
- **`AutomationResult` (`@objectstack/spec/contracts`) gains two optional members.** `userMessage` has `ApiErrorSchema.userMessage`'s semantics: it is producer-marked end-user text, present only when the inner refusal's producer opted in at throw time, and it never replaces `error`. `innerRefusal` is `{ code, message }`. Its `code` is the wire spelling a record save of the same write answers in `error.code` (`UNIQUE_VIOLATION` for a unique refusal), parsed against the same closed `ErrorCode` set `ApiErrorSchema.code` uses. `TriggerFlowResponseSchema.data` mirrors both, so the `TriggerFlowResponse['data']` = `AutomationResult` parity pin holds.
12+
- **New `FlowInnerRefusalSchema` and `FlowFailureDetailsSchema` (`@objectstack/spec/api`).** The second one is the `error.details` of the `400 FLOW_FAILED` arm of the trigger, resume, `/actions` and endpoint doors, declared once: `errorMessage?`, `summary?` and `innerRefusal?`. On the resume door, `ResumeFailureDetailsSchema`'s `runId` / `status` / `repairable` sit beside them in the same object, and each schema parses its own members.
13+
- **The #9585 `/actions` ceiling, relaxed by the ruling.** That ceiling was exactly two keys in `error.details` (`errorMessage`, `summary`) and no new schema key. Ruling B′ adds one key and names it, `innerRefusal`, so the ceiling is now the three keys `FlowFailureDetailsSchema` declares, at every door.
14+
- **Unchanged.** `error.message` keeps each door's wording byte for byte, `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union the #9378 ruling settled. The inner code is nested in `innerRefusal`, so no new ledger code is added. Every new member is optional: an envelope without them parses exactly as before.
15+
16+
**For consumers.** A flow runner can show a failed run's refusal the way a record form shows a save's refusal. Read the envelope's top-level `userMessage` when it is present. Otherwise read `details.innerRefusal` (`code` and `message`), and fall back to `message` last. A client running behind the server ignores the new members and keeps its current behaviour.

0 commit comments

Comments
 (0)