Skip to content
15 changes: 15 additions & 0 deletions .changeset/flow-end-node-refused-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": minor
---

A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`.

Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type.

The contract, declared here first (the engine and runner halves follow in their own packages):

- **`end` node config** — `EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was.
- **Run row** — `ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run.
- **Result / wire** — `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast.

Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before.
58 changes: 56 additions & 2 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Each node performs a specific action in the flow.
| Type | Description |
| :--- | :--- |
| `start` | Flow entry point |
| `end` | Flow termination |
| `end` | Flow termination — `config.outcome` says how: `completed` (default) or `refused` with an interpolated `message` ([below](#end-node-outcome)) |
| `decision` | Conditional branching (if/else) |
| `assignment` | Set variable values |
| `loop` | Structured iteration **container** — runs a body region once per item (ADR-0031) |
Expand Down Expand Up @@ -426,6 +426,60 @@ bound to `config.idVariable` so a later step can reference it.
This is how a single flow walks the user through several full object forms in
sequence (e.g. lead → account → opportunity), each step saving its own record.

### Ending a run — `completed` or `refused` [#end-node-outcome]

Every `end` used to mean "completed". The terminal node now declares its
**outcome**, so a flow can say *refuse this, and here is why, for this record*
instead of dressing a refusal up as a message-only `screen` — an input step that
renders **Submit** and, on submit, resumes to `end`, whose runner then toasts
`Flow "…" completed` at a user who was just told the opposite (maintainer
ruling 2026-09-05, option 2′: a first-class outcome on the existing node, not a
second terminal node type).

```typescript
{
id: 'refuse_duplicate',
type: 'end',
label: 'Refused — duplicate',
config: {
outcome: 'refused', // 'completed' (default) | 'refused'
message: 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}',
},
}
```

- `outcome: 'refused'` is a **terminal state, never resumed**, and it is
**distinct from `failed`** — a refusal is a successful evaluation that says
no; nothing threw. The run row records `status: 'refused'` with the rendered
text as `refusalMessage`, and the trigger / resume response carries the same
(`success: true`, `status: 'refused'`, `refusalMessage` — and **no**
`successMessage`, so there is nothing to toast).
- `message` is a `{token}` template interpolated at run time **exactly like a
`screen` node's `description`**, so the text names the record. It is
**required** when `outcome` is `refused` (a refusal without text is the shape
this replaces) and **refused** on a completed end (nothing would ever render
it — the key would be a silent no-op). The config is strict: an undeclared key
is a parse error naming the intended one (`reason` → `message`, `status` →
`outcome`).
- A runner shows `refusalMessage` with **Close only** — no Submit, no
`Flow "…" completed` toast; the invoking action's own `successMessage` stays
suppressed exactly as it is behind a paused run.

Reach the refusing `end` from a `decision` edge like any other branch, and keep
every write behind the branch the refusal never takes. Because `end` is
structural (no executor, no descriptor), the flow parse itself applies the
contract — `outcome: 'refused'` with no `message` is refused at
`nodes[i].config.message`, at registration and by `objectstack validate` alike.
An `end` node with no `config` parses exactly as before.

<Callout type="warn" title="Declared first — the engine and runner halves follow">
This page states the contract (`@objectstack/spec`). The engine half —
`service-automation` stamping `refused` and persisting the rendered message at
the `end` node (#15788) — and the runner half — the console `FlowRunner`
rendering Close-only (objectui#7707) — land separately. Until both do, a
`refused` end parses and registers but the run still ends as `completed`.
</Callout>

## Structured control flow (ADR-0031)

`loop`, `parallel`, and `try_catch` are **structured control-flow constructs** —
Expand Down Expand Up @@ -917,7 +971,7 @@ Each run's `steps[]` records every executed node — including loop iterations,
parallel branch bodies, and try/catch region steps — which the Studio flow
designer surfaces, nested by iteration / branch / handler, in its **Runs** side
panel. Recent runs are held in an in-memory ring buffer; terminal runs
(completed / failed) are also mirrored to `sys_automation_run` as durable
(completed / failed / refused — the last with its rendered `refusalMessage`) are also mirrored to `sys_automation_run` as durable
history with a bounded step log, so `listRuns` / `getRun` still report a run's
status, steps, and failure reason after a restart or ring-buffer eviction.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.

| Assumption | Reality | Anchor |
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702` |
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10306`–`10323` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1581` (#3493 / #6640) |
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ const result = AutomationApiErrorCode.parse(data);
| **flowName** | `string` | ✅ | Machine name of the executed flow |
| **flowVersion** | `integer` | optional | Version of the flow that was executed |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>` | ✅ | Current execution status |
| **refusalMessage** | `string` | optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` | ✅ | What triggered this execution |
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Ordered list of executed steps |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | Per-run rollup: records selected / acted on, gate skips, per-node status |
Expand Down Expand Up @@ -469,7 +470,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Flow machine name (snake_case) |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` | optional | Filter by execution status |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
| **cursor** | `string` | optional | Cursor for pagination |

Expand Down Expand Up @@ -607,11 +608,12 @@ const result = AutomationApiErrorCode.parse(data);
| **error** | `string` | optional | Error message if execution failed |
| **durationMs** | `number` | optional | Execution duration in milliseconds |
| **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). |
| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded` means the run reached a terminal state. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. |
| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded' \| 'refused'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded`/`refused` means the run reached a terminal state. `refused` is a first-class refusal: the flow reached an `end` node declaring `outcome: 'refused'` — a successful evaluation that said no, so `success` is true, `successMessage` is absent and the per-record reason is on `refusalMessage`; a runner shows it with Close only. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. |
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
| **refusalMessage** | `string` | optional | Rendered refusal, set when `status` is `refused` - the `end` node's `message` template interpolated against the run's variables, so it names the record. Authored per-record text (not a flow-level copy like the two above); absent on every other status. A runner shows it with Close only |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |


Expand Down
20 changes: 17 additions & 3 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ description: Builtin Node Config protocol schemas

Config contracts for the remaining flat builtins — the CRUD quartet
(`get_record` / `create_record` / `update_record` / `delete_record`),
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
`screen`, `map` (#4045), since #14149 `assignment`'s value contract and,
since #14945, the structural `end` node's outcome (`EndConfigSchema`, the
one contract here the FLOW PARSE applies rather than an executor).
Sibling of `io-node-config.zod.ts` (notify / http) and `control-flow.zod.ts`
(loop / parallel / try_catch).

Expand Down Expand Up @@ -84,8 +86,8 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, EndConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, EndConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

// Validate data
const result = AssignmentConfigSchema.parse(data);
Expand Down Expand Up @@ -151,6 +153,18 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| **multi** | `boolean` | optional | Declare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine) |


---

## EndConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **outcome** | `Enum<'completed' \| 'refused'>` | optional (default: `"completed"`) | How the run ends when it reaches this node. `completed` (the default) is the ordinary terminal. `refused` is a first-class refusal: the run records `refused` — distinct from `failed`, a refusal is a successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows the message with Close only: no Submit, no completion toast. |
| **message** | `string` | optional | Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op. |


---

## GetRecordConfig
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/automation/execution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ const result = CheckpointSchema.parse(data);
| **id** | `string` | ✅ | Execution instance ID |
| **flowName** | `string` | ✅ | Machine name of the executed flow |
| **flowVersion** | `integer` | optional | Version of the flow that was executed |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` | ✅ | Current execution status |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | ✅ | Current execution status |
| **refusalMessage** | `string` | optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` | ✅ | What triggered this execution |
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Ordered list of executed steps |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | Per-run rollup: records selected / acted on, gate skips, per-node status |
Expand Down Expand Up @@ -174,6 +175,7 @@ const result = CheckpointSchema.parse(data);
* `cancelled`
* `timed_out`
* `retrying`
* `refused`


---
Expand Down Expand Up @@ -347,7 +349,7 @@ const result = CheckpointSchema.parse(data);
| **nextRunAt** | `string` | optional | Next scheduled execution timestamp |
| **lastRunAt** | `string` | optional | Last execution timestamp |
| **lastExecutionId** | `string` | optional | Execution ID of the last run |
| **lastRunStatus** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` | optional | Status of the last run |
| **lastRunStatus** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Status of the last run |
| **totalRuns** | `integer` | optional (default: `0`) | Total number of executions |
| **consecutiveFailures** | `integer` | optional (default: `0`) | Consecutive failed executions |
| **startDate** | `string` | optional | Schedule effective start date |
Expand Down
Loading
Loading