Skip to content

Commit 3f89967

Browse files
claude[bot]os-project-managerclaude
authored
feat(spec): the flow end node declares its outcome — refused with an interpolated message, and the run vocabulary gains refused (#15889)
* feat(spec): the flow end node declares its outcome — refused with an interpolated message (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(spec): regenerate the automation surface, docs references and ledgers for the end-node outcome (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(spec): regenerate the merged tree's os-regen artifacts after merging origin/main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(spec): regenerate the merged tree's reference docs and census anchor after merging origin/main (lap 2) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: os-dev <pm@objectstack.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 904e707 commit 3f89967

22 files changed

Lines changed: 695 additions & 37 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`.
6+
7+
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.
8+
9+
The contract, declared here first (the engine and runner halves follow in their own packages):
10+
11+
- **`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.
12+
- **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.
13+
- **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.
14+
15+
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.

content/docs/automation/flows.mdx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Each node performs a specific action in the flow.
9999
| Type | Description |
100100
| :--- | :--- |
101101
| `start` | Flow entry point |
102-
| `end` | Flow termination |
102+
| `end` | Flow termination `config.outcome` says how: `completed` (default) or `refused` with an interpolated `message` ([below](#end-node-outcome)) |
103103
| `decision` | Conditional branching (if/else) |
104104
| `assignment` | Set variable values |
105105
| `loop` | Structured iteration **container** — runs a body region once per item (ADR-0031) |
@@ -426,6 +426,60 @@ bound to `config.idVariable` so a later step can reference it.
426426
This is how a single flow walks the user through several full object forms in
427427
sequence (e.g. lead → account → opportunity), each step saving its own record.
428428

429+
### Ending a run — `completed` or `refused` [#end-node-outcome]
430+
431+
Every `end` used to mean "completed". The terminal node now declares its
432+
**outcome**, so a flow can say *refuse this, and here is why, for this record*
433+
instead of dressing a refusal up as a message-only `screen` — an input step that
434+
renders **Submit** and, on submit, resumes to `end`, whose runner then toasts
435+
`Flow "…" completed` at a user who was just told the opposite (maintainer
436+
ruling 2026-09-05, option 2′: a first-class outcome on the existing node, not a
437+
second terminal node type).
438+
439+
```typescript
440+
{
441+
id: 'refuse_duplicate',
442+
type: 'end',
443+
label: 'Refused — duplicate',
444+
config: {
445+
outcome: 'refused', // 'completed' (default) | 'refused'
446+
message: 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}',
447+
},
448+
}
449+
```
450+
451+
- `outcome: 'refused'` is a **terminal state, never resumed**, and it is
452+
**distinct from `failed`** — a refusal is a successful evaluation that says
453+
no; nothing threw. The run row records `status: 'refused'` with the rendered
454+
text as `refusalMessage`, and the trigger / resume response carries the same
455+
(`success: true`, `status: 'refused'`, `refusalMessage` — and **no**
456+
`successMessage`, so there is nothing to toast).
457+
- `message` is a `{token}` template interpolated at run time **exactly like a
458+
`screen` node's `description`**, so the text names the record. It is
459+
**required** when `outcome` is `refused` (a refusal without text is the shape
460+
this replaces) and **refused** on a completed end (nothing would ever render
461+
it — the key would be a silent no-op). The config is strict: an undeclared key
462+
is a parse error naming the intended one (`reason``message`, `status`
463+
`outcome`).
464+
- A runner shows `refusalMessage` with **Close only** — no Submit, no
465+
`Flow "…" completed` toast; the invoking action's own `successMessage` stays
466+
suppressed exactly as it is behind a paused run.
467+
468+
Reach the refusing `end` from a `decision` edge like any other branch, and keep
469+
every write behind the branch the refusal never takes. Because `end` is
470+
structural (no executor, no descriptor), the flow parse itself applies the
471+
contract — `outcome: 'refused'` with no `message` is refused at
472+
`nodes[i].config.message`, at registration and by `objectstack validate` alike.
473+
An `end` node with no `config` parses exactly as before.
474+
475+
<Callout type="warn" title="Declared first — the engine and runner halves follow">
476+
This page states the contract (`@objectstack/spec`). The engine half —
477+
`service-automation` stamping `refused` and persisting the rendered message at
478+
the `end` node (#15788) — and the runner half — the console `FlowRunner`
479+
rendering Close-only (objectui#7707) — land separately. Until both do, a
480+
`refused` end parses and registers but the run still ends as `completed`.
481+
</Callout>
482+
429483
## Structured control flow (ADR-0031)
430484

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

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.
193193

194194
| Assumption | Reality | Anchor |
195195
|:---|:---|:---|
196-
| "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` |
196+
| "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` |
197197
| "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) |
198198
| "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` |
199199
| "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) |

content/docs/references/api/automation-api.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ const result = AutomationApiErrorCode.parse(data);
399399
| **flowName** | `string` || Machine name of the executed flow |
400400
| **flowVersion** | `integer` | optional | Version of the flow that was executed |
401401
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>` || Current execution status |
402+
| **refusalMessage** | `string` | optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
402403
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` || What triggered this execution |
403404
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Ordered list of executed steps |
404405
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | Per-run rollup: records selected / acted on, gate skips, per-node status |
@@ -469,7 +470,7 @@ const result = AutomationApiErrorCode.parse(data);
469470
| Property | Type | Required | Description |
470471
| :--- | :--- | :--- | :--- |
471472
| **name** | `string` || Flow machine name (snake_case) |
472-
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` | optional | Filter by execution status |
473+
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
473474
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
474475
| **cursor** | `string` | optional | Cursor for pagination |
475476

@@ -607,11 +608,12 @@ const result = AutomationApiErrorCode.parse(data);
607608
| **error** | `string` | optional | Error message if execution failed |
608609
| **durationMs** | `number` | optional | Execution duration in milliseconds |
609610
| **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). |
610-
| **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. |
611+
| **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. |
611612
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
612613
| **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. |
613614
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
614615
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
616+
| **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 |
615617
| **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). |
616618

617619

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ description: Builtin Node Config protocol schemas
77

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

@@ -84,8 +86,8 @@ Deliberately absent:
8486
## TypeScript Usage
8587

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

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

153155

156+
---
157+
158+
## EndConfig
159+
160+
### Properties
161+
162+
| Property | Type | Required | Description |
163+
| :--- | :--- | :--- | :--- |
164+
| **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. |
165+
| **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. |
166+
167+
154168
---
155169

156170
## GetRecordConfig

content/docs/references/automation/execution.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ const result = CheckpointSchema.parse(data);
103103
| **id** | `string` || Execution instance ID |
104104
| **flowName** | `string` || Machine name of the executed flow |
105105
| **flowVersion** | `integer` | optional | Version of the flow that was executed |
106-
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` || Current execution status |
106+
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` || Current execution status |
107+
| **refusalMessage** | `string` | optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
107108
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` || What triggered this execution |
108109
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Ordered list of executed steps |
109110
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | Per-run rollup: records selected / acted on, gate skips, per-node status |
@@ -174,6 +175,7 @@ const result = CheckpointSchema.parse(data);
174175
* `cancelled`
175176
* `timed_out`
176177
* `retrying`
178+
* `refused`
177179

178180

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

0 commit comments

Comments
 (0)