Repository navigation
Commit 045858f
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
- .changeset
- content/docs/references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- runtime/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
0 commit comments