You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Browse filesBrowse the repository at this point in the historyBrowse files
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>
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.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.
193
193
194
194
| Assumption | Reality | Anchor |
195
195
|:---|:---|:---|
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`|
197
197
| "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)|
198
198
| "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`|
199
199
| "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) |
Copy file name to clipboardExpand all lines: content/docs/references/api/automation-api.mdx
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -399,6 +399,7 @@ const result = AutomationApiErrorCode.parse(data);
399
399
|**flowName**|`string`| ✅ | Machine name of the executed flow |
400
400
|**flowVersion**|`integer`| optional | Version of the flow that was executed |
401
401
|**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. |
402
403
|**trigger**|`{ type: string; recordId?: string; object?: string; userId?: string; … }`| ✅ | What triggered this execution |
|**limit**|`integer`| optional (default: `20`) | Maximum number of runs to return |
474
475
|**cursor**|`string`| optional | Cursor for pagination |
475
476
@@ -607,11 +608,12 @@ const result = AutomationApiErrorCode.parse(data);
607
608
|**error**|`string`| optional | Error message if execution failed |
608
609
|**durationMs**|`number`| optional | Execution duration in milliseconds |
609
610
|**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. |
611
612
|**runId**|`string`| optional | Run id - set when `status` is `paused`, so callers can resume it |
612
613
|**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. |
613
614
|**successMessage**|`string`| optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
614
615
|**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 |
615
617
|**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). |
@@ -151,6 +153,18 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
151
153
|**multi**|`boolean`| optional | Declare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine) |
152
154
153
155
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. |
|**refusalMessage**|`string`| optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
107
108
|**trigger**|`{ type: string; recordId?: string; object?: string; userId?: string; … }`| ✅ | What triggered this execution |
|**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);
174
175
*`cancelled`
175
176
*`timed_out`
176
177
*`retrying`
178
+
*`refused`
177
179
178
180
179
181
---
@@ -347,7 +349,7 @@ const result = CheckpointSchema.parse(data);
347
349
|**nextRunAt**|`string`| optional | Next scheduled execution timestamp |
348
350
|**lastRunAt**|`string`| optional | Last execution timestamp |
349
351
|**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 |
351
353
|**totalRuns**|`integer`| optional (default: `0`) | Total number of executions |
0 commit comments