diff --git a/.changeset/flow-end-node-refused-outcome.md b/.changeset/flow-end-node-refused-outcome.md
new file mode 100644
index 0000000000..38741c5de7
--- /dev/null
+++ b/.changeset/flow-end-node-refused-outcome.md
@@ -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.
diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx
index 38db4ff5ec..253306e2c9 100644
--- a/content/docs/automation/flows.mdx
+++ b/content/docs/automation/flows.mdx
@@ -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) |
@@ -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.
+
+
+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`.
+
+
## Structured control flow (ADR-0031)
`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,
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.
diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx
index 815ccac008..1a28358dac 100644
--- a/content/docs/permissions/system-context.mdx
+++ b/content/docs/permissions/system-context.mdx
@@ -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) |
diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index 805cf08a2e..83077d417a 100644
--- a/content/docs/references/api/automation-api.mdx
+++ b/content/docs/references/api/automation-api.mdx
@@ -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 |
@@ -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 |
@@ -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). |
diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx
index 497748f8a8..7b06b7fd45 100644
--- a/content/docs/references/automation/builtin-node-config.mdx
+++ b/content/docs/references/automation/builtin-node-config.mdx
@@ -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).
@@ -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);
@@ -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
diff --git a/content/docs/references/automation/execution.mdx b/content/docs/references/automation/execution.mdx
index a708052d72..6236cf2b38 100644
--- a/content/docs/references/automation/execution.mdx
+++ b/content/docs/references/automation/execution.mdx
@@ -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 |
@@ -174,6 +175,7 @@ const result = CheckpointSchema.parse(data);
* `cancelled`
* `timed_out`
* `retrying`
+* `refused`
---
@@ -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 |
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 0311171044..6d443185e9 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1591 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
-| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
+| [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **200** | **1591** | 14 protocol modules |
+| **Total** | **200** | **1592** | 14 protocol modules |
---
@@ -103,7 +103,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
## Automation Protocol
-**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**
+**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 73 schemas**
Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.
@@ -111,7 +111,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
-| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
+| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index 5bae84621a..1716a37d5f 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
-| Object sites in them | 440 |
+| Object sites in them | 441 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |
@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
-| `automation/` | 67 | 42 | 0 | 1 | 24 |
+| `automation/` | 68 | 43 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
-| **total** | **440** | **309** | **6** | **1** | **124** |
+| **total** | **441** | **310** | **6** | **1** | **124** |
## File-level triage — site counts
@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
-| `builtin-node-config.zod.ts` | 9 |
+| `builtin-node-config.zod.ts` | 10 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
-| **total** | **67** |
+| **total** | **68** |
### `security/` — sites
@@ -204,7 +204,7 @@ over it is here.
### `automation/` — open
-**24 strip of 67**, in 5 file(s).
+**24 strip of 68**, in 5 file(s).
| File | Strip | Sites |
|---|---|---|
@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
-| **total** | **24** | **67** |
+| **total** | **24** | **68** |
| Bucket | Sites |
|---|---|
diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json
index 3a9077df7e..d3f87a2aea 100644
--- a/packages/spec/api-surface/automation.json
+++ b/packages/spec/api-surface/automation.json
@@ -87,6 +87,9 @@
"DeleteRecordConfig (type)",
"DeleteRecordConfigParsed (type)",
"DeleteRecordConfigSchema (const)",
+ "EndConfig (type)",
+ "EndConfigParsed (type)",
+ "EndConfigSchema (const)",
"ExecutionError (type)",
"ExecutionErrorParsed (type)",
"ExecutionErrorSchema (const)",
diff --git a/packages/spec/authorable-defaults/automation.json b/packages/spec/authorable-defaults/automation.json
index 47bd3a69c8..d0115dc9f3 100644
--- a/packages/spec/authorable-defaults/automation.json
+++ b/packages/spec/authorable-defaults/automation.json
@@ -35,6 +35,7 @@
"automation/ConcurrencyPolicy:lockScope = \"global\"",
"automation/ConcurrencyPolicy:maxConcurrent = 1",
"automation/ConcurrencyPolicy:onConflict = \"queue\"",
+ "automation/EndConfig:outcome = \"completed\"",
"automation/ExecutionError:retryable = false",
"automation/Flow:runAs = \"user\"",
"automation/Flow:status = \"draft\"",
diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json
index f704ed7a03..23619610b4 100644
--- a/packages/spec/authorable-surface/automation.json
+++ b/packages/spec/authorable-surface/automation.json
@@ -96,6 +96,8 @@
"automation/DeleteRecordConfig:filter",
"automation/DeleteRecordConfig:multi",
"automation/DeleteRecordConfig:objectName",
+ "automation/EndConfig:message",
+ "automation/EndConfig:outcome",
"automation/ExecutionError:code",
"automation/ExecutionError:context",
"automation/ExecutionError:executionId",
@@ -112,6 +114,7 @@
"automation/ExecutionLog:flowName",
"automation/ExecutionLog:flowVersion",
"automation/ExecutionLog:id",
+ "automation/ExecutionLog:refusalMessage",
"automation/ExecutionLog:runAs",
"automation/ExecutionLog:startedAt",
"automation/ExecutionLog:status",
diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json
index caa2ec3c05..d943434410 100644
--- a/packages/spec/declaration-map/automation.json
+++ b/packages/spec/declaration-map/automation.json
@@ -52,6 +52,8 @@
"DecisionOutputDefSchema": "automation/DecisionOutputDef",
"DeleteRecordConfig": "automation/DeleteRecordConfig",
"DeleteRecordConfigSchema": "automation/DeleteRecordConfig",
+ "EndConfig": "automation/EndConfig",
+ "EndConfigSchema": "automation/EndConfig",
"ExecutionError": "automation/ExecutionError",
"ExecutionErrorSchema": "automation/ExecutionError",
"ExecutionErrorSeverity": "automation/ExecutionErrorSeverity",
diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json
index d1c390eac0..aadc165a7b 100644
--- a/packages/spec/export-origins/automation.json
+++ b/packages/spec/export-origins/automation.json
@@ -87,6 +87,9 @@
"DeleteRecordConfig": "src/automation/builtin-node-config.zod.ts#DeleteRecordConfig (type)",
"DeleteRecordConfigParsed": "src/automation/builtin-node-config.zod.ts#DeleteRecordConfigParsed (type)",
"DeleteRecordConfigSchema": "src/automation/builtin-node-config.zod.ts#DeleteRecordConfigSchema (const)",
+ "EndConfig": "src/automation/builtin-node-config.zod.ts#EndConfig (type)",
+ "EndConfigParsed": "src/automation/builtin-node-config.zod.ts#EndConfigParsed (type)",
+ "EndConfigSchema": "src/automation/builtin-node-config.zod.ts#EndConfigSchema (const)",
"ExecutionError": "src/automation/execution.zod.ts#ExecutionError (type)",
"ExecutionErrorParsed": "src/automation/execution.zod.ts#ExecutionErrorParsed (type)",
"ExecutionErrorSchema": "src/automation/execution.zod.ts#ExecutionErrorSchema (const)",
diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json
index 85bab7a226..7a28689cb9 100644
--- a/packages/spec/json-schema.manifest/automation.json
+++ b/packages/spec/json-schema.manifest/automation.json
@@ -28,6 +28,7 @@
"automation/DecisionConfig",
"automation/DecisionOutputDef",
"automation/DeleteRecordConfig",
+ "automation/EndConfig",
"automation/ExecutionError",
"automation/ExecutionErrorSeverity",
"automation/ExecutionLog",
diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts
index c341ba1ce7..80689d0a42 100644
--- a/packages/spec/src/api/automation-api.zod.ts
+++ b/packages/spec/src/api/automation-api.zod.ts
@@ -340,10 +340,16 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext
// `stranded` is the contract half of the #13937 shape-4 ruling (#14384);
// the condition is #13909's. Mirrors `AutomationResult.status` member for
// member — the pin is `contracts/automation-result-status.pin.test.ts`.
- status: z.enum(['completed', 'paused', 'failed', 'stranded']).optional().describe(
+ // `refused` (#14945) mirrors the same way: the run reached an `end` node
+ // declaring `outcome: 'refused'` — terminal, never resumed, distinct from
+ // `failed`; its rendered text rides on `refusalMessage` below.
+ status: z.enum(['completed', 'paused', 'failed', 'stranded', 'refused']).optional().describe(
'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 '
+ + '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, '
@@ -363,6 +369,12 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext
errorMessage: z.string().optional().describe(
'Friendly terminal message copied from the flow definition on failure',
),
+ refusalMessage: z.string().optional().describe(
+ '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: FlowRunSummarySchema.optional().describe(
'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 '
diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts
index 88381ff6f7..7d8fae16a0 100644
--- a/packages/spec/src/automation/builtin-node-config.zod.ts
+++ b/packages/spec/src/automation/builtin-node-config.zod.ts
@@ -5,7 +5,9 @@
*
* 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).
*
@@ -475,6 +477,118 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({
export type ScreenConfig = z.input;
export type ScreenConfigParsed = z.infer;
+// ─── end ─────────────────────────────────────────────────────────────
+
+/**
+ * `end` node config — how the run ENDS (#14945).
+ *
+ * `end` is a structural node (`FLOW_STRUCTURAL_NODE_TYPES`): the engine
+ * terminates the run on reaching it with no registered executor, so — unlike
+ * every other contract in this module — nothing `parse()`s this shape at
+ * execute time and no descriptor `configSchema` closes it at `registerFlow()`.
+ * The one door an `end` node's config passes through is the flow parse itself,
+ * which is why `FlowNodeSchema` (flow.zod.ts) applies this contract to every
+ * `type: 'end'` node it parses, at any region depth, and writes the parsed
+ * (defaulted) config back.
+ *
+ * ## `outcome` — the terminal state the run records
+ *
+ * Every terminal of a flow used to be "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 is an INPUT step wearing a notice's clothes — it renders
+ * Submit, and submitting resumes the run to `end`, whose runner toasts
+ * `Flow "…" completed` at a user who was just told "this is refused" (the
+ * hotcrm lead-conversion refusal, hotcrm#1288 / hotcrm#1555, is the measured
+ * case).
+ *
+ * Maintainer ruling 2026-09-05 (option 2′): the refusal is a first-class
+ * OUTCOME of the existing terminal node, not a second terminal node type —
+ * `outcome: 'refused'` with an interpolated `message`. A refused end is a
+ * terminal state: the run records `refused` (`ExecutionStatus`, distinct from
+ * `failed` — a refusal is a successful evaluation that says no) together with
+ * the rendered message (`ExecutionLog.refusalMessage`), it is never resumed,
+ * and a runner renders the message with Close only — no Submit, no completion
+ * toast; the invoking action's `successMessage` stays suppressed. The halves
+ * land in sequence: this contract, then the engine's `end` handling (stamping
+ * the outcome, persisting the rendered message), then the runner.
+ *
+ * ## `message` — required by a refusal, refused by a completion
+ *
+ * `message` is a `{token}` template rendered at the engine's existing
+ * interpolation points, exactly as a `screen` node's `description` is —
+ * `'Refused: {record.name} is a confirmed duplicate'` yields per-record text at
+ * run time. Two refinements keep the pair honest, in both directions:
+ *
+ * - `outcome: 'refused'` with no `message` is REFUSED — a refusal without
+ * text is exactly the shape this contract exists to make expressible, and
+ * an author who omits it ships a refusal nobody can explain.
+ * - `message` with `outcome: 'completed'` (or omitted) is REFUSED — a
+ * completion renders nothing, so the key would be a silent no-op: the kind
+ * of key an AI author sets, sees no error for, and reports "done" over.
+ */
+export const EndConfigSchema = lazySchema(() => strictObject({
+ surface: 'this end node config',
+ history:
+ 'Until this shape was declared, an `end` node had no config contract at all — any key was accepted at parse '
+ + 'and ignored at run time, so a refusal an author wrote here shipped as a plain completion.',
+ aliases: {
+ // The words a refusal arrives spelled in — `reason` / `text` from prose,
+ // `description` from the screen node an author migrates the refusal OUT
+ // of (the card's own workaround), `status` / `result` for the outcome.
+ reason: 'message',
+ text: 'message',
+ description: 'message',
+ status: 'outcome',
+ result: 'outcome',
+ },
+ guidance: {
+ title:
+ 'An `end` node has no heading — a runner shows `message` alone under the flow\'s label. Put the text in '
+ + '`message`; a `title` belongs to a `screen` node.',
+ },
+}, {
+ /** Terminal state the run records: `completed` (default) or `refused`. */
+ outcome: z.enum(['completed', 'refused']).default('completed').describe(
+ '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.',
+ ),
+ /** Why the run was refused. Interpolates `{token}` like a screen `description`. */
+ message: z.string().min(1).optional().describe(
+ '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.',
+ ),
+}).superRefine((config, ctx) => {
+ if (config.outcome === 'refused') {
+ if (config.message === undefined) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['message'],
+ message:
+ "`outcome: 'refused'` requires a `message` — a refusal with no text is the shape this contract exists to "
+ + 'replace (a screen pretending to be a notice). Say why, as a `{token}` template so the text names the '
+ + "record: `message: 'Refused: {record.name} is a confirmed duplicate'`.",
+ });
+ }
+ return;
+ }
+ if (config.message !== undefined) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['message'],
+ message:
+ "`message` is only rendered when `outcome` is 'refused' — on a completed end nothing shows it, so the key "
+ + "would be a silent no-op. Either set `outcome: 'refused'` or delete `message`.",
+ });
+ }
+}));
+
+export type EndConfig = z.input;
+export type EndConfigParsed = z.infer;
+
// ─── map ─────────────────────────────────────────────────────────────
/**
diff --git a/packages/spec/src/automation/end-node-outcome.test.ts b/packages/spec/src/automation/end-node-outcome.test.ts
new file mode 100644
index 0000000000..086edab107
--- /dev/null
+++ b/packages/spec/src/automation/end-node-outcome.test.ts
@@ -0,0 +1,295 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#14945] A flow can REFUSE with per-record text — the `end` node's
+ * `outcome` / `message` contract, pinned at every door it has.
+ *
+ * Before-state, measured on `c99449ab5` (2026-09-05): `FlowSchema.safeParse`
+ * ACCEPTED every probe below — `{ outcome: 'refused' }` with no message, a
+ * bogus outcome, an undeclared key — because `end` is structural (no executor,
+ * no descriptor) and the node `config` slot is an open record, so an `end`
+ * node's config was an unvalidated bag that the engine then ignored: a refusal
+ * an author wrote shipped as a plain completion. `ExecutionStatus` refused
+ * `'refused'`.
+ *
+ * Maintainer ruling 2026-09-05 (option 2′): the refusal is a first-class
+ * outcome of the existing terminal node. These pins are the CONTRACT's; the
+ * engine's (a two-record fixture yielding per-record text) and the runner's
+ * (no Submit, no completion toast, `successMessage` still silent) belong to
+ * the services and objectui halves.
+ */
+import { describe, it, expect } from 'vitest';
+import { EndConfigSchema } from './builtin-node-config.zod';
+import { FlowSchema, FlowNodeSchema, defineFlow, type Flow } from './flow.zod';
+import { validateControlFlow } from './control-flow.zod';
+import { ExecutionLogSchema, ExecutionStatus } from './execution.zod';
+import { formatZodError } from '../shared/error-map.zod';
+
+const REFUSAL = 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}';
+
+/** The card-shape flow: start → end, with the end node's config under test. */
+const flowEndingWith = (config: Record | undefined): Flow => ({
+ name: 'refuse_flow',
+ label: 'Refuse flow',
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'end', type: 'end', label: 'End', ...(config === undefined ? {} : { config }) },
+ ],
+ edges: [{ id: 'e1', source: 'start', target: 'end' }],
+});
+
+const endConfigOf = (flow: { nodes: Array<{ config?: unknown }> }) => flow.nodes[1].config;
+
+describe('EndConfigSchema — the `end` node contract (#14945)', () => {
+ it('defaults `outcome` to `completed` on an empty config', () => {
+ expect(EndConfigSchema.parse({})).toEqual({ outcome: 'completed' });
+ expect(EndConfigSchema.parse({ outcome: 'completed' })).toEqual({ outcome: 'completed' });
+ });
+
+ it('accepts the card shape — `refused` with an interpolated `message` — and preserves the template verbatim', () => {
+ expect(EndConfigSchema.parse({ outcome: 'refused', message: REFUSAL }))
+ .toEqual({ outcome: 'refused', message: REFUSAL });
+ });
+
+ it("REFUSES `outcome: 'refused'` without a `message` — the issue sits on `message` and says why", () => {
+ const result = EndConfigSchema.safeParse({ outcome: 'refused' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues).toHaveLength(1);
+ const [issue] = result.error.issues;
+ expect(issue.code).toBe('custom');
+ expect(issue.path).toEqual(['message']);
+ expect(issue.message).toContain("`outcome: 'refused'` requires a `message`");
+ expect(issue.message).toContain('{record.name}');
+ });
+
+ it('REFUSES an empty `message` on a refusal — a refusal without text, spelled as an empty string', () => {
+ const result = EndConfigSchema.safeParse({ outcome: 'refused', message: '' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['too_small', ['message']]]);
+ });
+
+ it.each([
+ ['explicit', { outcome: 'completed', message: 'never shown' }],
+ ['omitted', { message: 'never shown' }],
+ ])('REFUSES `message` on a completed end (outcome %s) — a key nothing would ever render', (_label, config) => {
+ const result = EndConfigSchema.safeParse(config);
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues).toHaveLength(1);
+ const [issue] = result.error.issues;
+ expect(issue.code).toBe('custom');
+ expect(issue.path).toEqual(['message']);
+ expect(issue.message).toContain("only rendered when `outcome` is 'refused'");
+ expect(issue.message).toContain('silent no-op');
+ });
+
+ it('REFUSES an outcome outside the two, as an enum violation on `outcome`', () => {
+ const result = EndConfigSchema.safeParse({ outcome: 'rejected' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['outcome']]]);
+ });
+
+ it('is strict — an undeclared key is refused with the surface named and the intended key suggested', () => {
+ const result = EndConfigSchema.safeParse({ outcome: 'refused', message: REFUSAL, reason: 'duplicate' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues).toHaveLength(1);
+ const [issue] = result.error.issues;
+ expect(issue.code).toBe('unrecognized_keys');
+ expect((issue as { keys?: string[] }).keys).toEqual(['reason']);
+ expect(issue.message).toContain('this end node config');
+ expect(issue.message).toContain('Did you mean `reason` → `message`?');
+ // The history line: what an undeclared key silently did before.
+ expect(issue.message).toContain('shipped as a plain completion');
+ });
+
+ it("names `message` for the screen-node spelling a refusal migrates OUT of (`description`), and `outcome` for `status`", () => {
+ const description = EndConfigSchema.safeParse({ outcome: 'refused', description: REFUSAL });
+ expect(description.success).toBe(false);
+ if (!description.success) {
+ expect(description.error.issues[0].message).toContain('Did you mean `description` → `message`?');
+ }
+ const status = EndConfigSchema.safeParse({ status: 'refused', message: REFUSAL });
+ expect(status.success).toBe(false);
+ if (!status.success) {
+ expect(status.error.issues[0].message).toContain('Did you mean `status` → `outcome`?');
+ }
+ });
+
+ it('tells an author reaching for `title` that an end has no heading', () => {
+ const result = EndConfigSchema.safeParse({ outcome: 'refused', message: REFUSAL, title: 'Refused' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues[0].message).toContain('An `end` node has no heading');
+ });
+});
+
+describe('FlowSchema applies the `end` contract — the structural node\'s only door (#14945)', () => {
+ it('accepts the card-shape probe and writes the parsed config back', () => {
+ const result = FlowSchema.safeParse(flowEndingWith({ outcome: 'refused', message: REFUSAL }));
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(endConfigOf(result.data)).toEqual({ outcome: 'refused', message: REFUSAL });
+ });
+
+ it('`outcome` omitted ⇒ parses with the default `completed` written back', () => {
+ const result = FlowSchema.safeParse(flowEndingWith({}));
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(endConfigOf(result.data)).toEqual({ outcome: 'completed' });
+ });
+
+ it('an `end` with no `config` at all is left without one — no config block materialised on a plain terminal', () => {
+ const result = FlowSchema.safeParse(flowEndingWith(undefined));
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect('config' in result.data.nodes[1]).toBe(false);
+ });
+
+ it("REFUSES `outcome: 'refused'` without a `message` at `nodes[i].config.message`", () => {
+ const result = FlowSchema.safeParse(flowEndingWith({ outcome: 'refused' }));
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues).toHaveLength(1);
+ const [issue] = result.error.issues;
+ expect(issue.code).toBe('custom');
+ expect(issue.path).toEqual(['nodes', 1, 'config', 'message']);
+ expect(issue.message).toContain("`outcome: 'refused'` requires a `message`");
+ });
+
+ it('renders that refusal through formatZodError as a line that names the key to add', () => {
+ const result = FlowSchema.safeParse(flowEndingWith({ outcome: 'refused' }));
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ const rendered = formatZodError(result.error);
+ expect(rendered).toContain('Validation failed (1 issue):');
+ expect(rendered).toContain("✗ nodes.1.config.message: `outcome: 'refused'` requires a `message`");
+ });
+
+ it.each([
+ ['explicit', { outcome: 'completed', message: 'never shown' }],
+ ['omitted', { message: 'never shown' }],
+ ])('REFUSES `message` on a completed end (outcome %s) at the same address', (_label, config) => {
+ const result = FlowSchema.safeParse(flowEndingWith(config));
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'config', 'message']]]);
+ });
+
+ it('REFUSES an undeclared key on the end config, anchored on the config, with the suggestion intact', () => {
+ const result = FlowSchema.safeParse(flowEndingWith({ outcome: 'refused', message: REFUSAL, reason: 'dup' }));
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', ['nodes', 1, 'config']]]);
+ expect(result.error.issues[0].message).toContain('Did you mean `reason` → `message`?');
+ });
+
+ it('REFUSES a bogus outcome as an enum violation at `nodes[i].config.outcome`', () => {
+ const result = FlowSchema.safeParse(flowEndingWith({ outcome: 'bogus' }));
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['nodes', 1, 'config', 'outcome']]]);
+ });
+
+ it('leaves every OTHER node type\'s `config` the open slot it was (ADR-0018) — `outcome` on a plugin node is not this contract\'s', () => {
+ const parsed = FlowNodeSchema.parse({
+ id: 'p', type: 'some_plugin_node', label: 'P', config: { outcome: 'refused', whatever: 1 },
+ });
+ expect(parsed.config).toEqual({ outcome: 'refused', whatever: 1 });
+ });
+
+ it('defineFlow round-trips the refusal config and refuses the same shapes with the same anchored issue', () => {
+ const accepted = defineFlow(flowEndingWith({ outcome: 'refused', message: REFUSAL }));
+ expect(endConfigOf(accepted)).toEqual({ outcome: 'refused', message: REFUSAL });
+ expect(endConfigOf(defineFlow(flowEndingWith({})))).toEqual({ outcome: 'completed' });
+
+ let caught: unknown;
+ try {
+ defineFlow(flowEndingWith({ outcome: 'refused' }));
+ } catch (error) {
+ caught = error;
+ }
+ const issues = (caught as { issues?: Array<{ code: string; path: PropertyKey[] }> })?.issues;
+ expect(issues).toBeDefined();
+ expect(issues?.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'config', 'message']]]);
+ });
+
+ it('a region-nested `end` is checked at the region door: the flow parse leaves the region raw, validateControlFlow refuses it by name', () => {
+ // `parseFlowNodeRegions` deliberately leaves a region it cannot parse
+ // untouched (the registration walk owns nested diagnostics, #4389), so the
+ // FLOW parse alone does not surface a nested refusal — the same boundary
+ // every other nested node key has. `validateControlFlow` re-parses the
+ // region through `FlowNodeSchema`, where this contract now lives, and
+ // throws with the same sentence.
+ const nested: Flow = {
+ name: 'nested_refusal',
+ label: 'Nested refusal',
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'each', type: 'loop', label: 'Each',
+ config: {
+ collection: '{items}',
+ body: {
+ nodes: [{ id: 'inner_end', type: 'end', label: 'Inner end', config: { outcome: 'refused' } }],
+ edges: [],
+ },
+ },
+ },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'each' },
+ { id: 'e2', source: 'each', target: 'end' },
+ ],
+ };
+ const parsed = FlowSchema.safeParse(nested);
+ expect(parsed.success).toBe(true);
+ if (!parsed.success) return;
+ let message = '';
+ try {
+ validateControlFlow(parsed.data);
+ } catch (error) {
+ message = (error as Error).message;
+ }
+ expect(message).toContain("loop 'each' body");
+ expect(message).toContain("`outcome: 'refused'` requires a `message`");
+ });
+});
+
+describe('the run row carries the refusal (#14945)', () => {
+ const run = {
+ id: 'exec_refused_001',
+ flowName: 'lead_conversion',
+ trigger: { type: 'api', recordId: 'lead_42', object: 'crm_lead' },
+ steps: [],
+ startedAt: '2026-09-05T08:00:00Z',
+ completedAt: '2026-09-05T08:00:01Z',
+ durationMs: 12,
+ };
+
+ it("`ExecutionStatus` names `refused` beside `failed` — two members, not one", () => {
+ expect(ExecutionStatus.options).toContain('refused');
+ expect(ExecutionStatus.options).toContain('failed');
+ });
+
+ it('a refused run parses with `refusalMessage` PRESERVED — the rendered per-record text, not the template', () => {
+ const parsed = ExecutionLogSchema.parse({
+ ...run,
+ status: 'refused',
+ refusalMessage: 'Refused: Acme Corp is a confirmed duplicate of Acme Corporation',
+ });
+ expect(parsed.status).toBe('refused');
+ expect(parsed.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate of Acme Corporation');
+ });
+
+ it('`refusalMessage` is optional — a completed or failed run carries none', () => {
+ expect(ExecutionLogSchema.parse({ ...run, status: 'completed' }).refusalMessage).toBeUndefined();
+ expect(ExecutionLogSchema.parse({ ...run, status: 'failed' }).refusalMessage).toBeUndefined();
+ });
+});
diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts
index 4f6d43e28f..0f3289abe8 100644
--- a/packages/spec/src/automation/execution.test.ts
+++ b/packages/spec/src/automation/execution.test.ts
@@ -17,12 +17,25 @@ import {
describe('ExecutionStatus', () => {
it('should accept all valid statuses', () => {
- const valid = ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying'];
+ const valid = ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying', 'refused'];
valid.forEach((v) => {
expect(() => ExecutionStatus.parse(v)).not.toThrow();
});
});
+ it('names the refused terminal (#14945) — appended LAST, so every `.options` index reader keeps its positions', () => {
+ expect(ExecutionStatus.options).toContain('refused');
+ expect(ExecutionStatus.options.at(-1)).toBe('refused');
+ expect(ExecutionStatus.options.slice(0, 8)).toEqual(
+ ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying'],
+ );
+ // Distinct members, not aliases: a refusal is a successful evaluation that
+ // said no, a failure is a throw.
+ expect(ExecutionStatus.safeParse('refused').success).toBe(true);
+ expect(ExecutionStatus.safeParse('failed').success).toBe(true);
+ expect(ExecutionStatus.safeParse('rejected').success).toBe(false);
+ });
+
it('should reject invalid statuses', () => {
expect(() => ExecutionStatus.parse('active')).toThrow();
expect(() => ExecutionStatus.parse('RUNNING')).toThrow();
diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts
index 1816cc7067..f0351cdb46 100644
--- a/packages/spec/src/automation/execution.zod.ts
+++ b/packages/spec/src/automation/execution.zod.ts
@@ -31,6 +31,12 @@ export const ExecutionStatus = z.enum([
'cancelled', // Manually cancelled
'timed_out', // Exceeded max execution time
'retrying', // Failed and retrying
+ // #14945 — the run reached an `end` node declaring `outcome: 'refused'`: a
+ // successful evaluation that said no. Terminal, never resumed, and DISTINCT
+ // from `failed` (nothing threw; the flow refused on purpose). The rendered
+ // refusal text rides beside it on the run row as `refusalMessage`. Appended
+ // last so every reader that indexes `.options` keeps its positions.
+ 'refused', // Terminal: the flow refused (an `end` node with outcome: 'refused')
]);
export type ExecutionStatus = z.input;
@@ -300,6 +306,21 @@ export const ExecutionLogSchema = lazySchema(() => z.object({
/** Execution status */
status: ExecutionStatus.describe('Current execution status'),
+ /**
+ * #14945: the rendered refusal. Set when `status` is `refused` — the `end`
+ * node's `message` template, interpolated against the run's variables at
+ * the moment the run reached it (per-record text, the same rendering a
+ * `screen` node's `description` gets). Absent on every other status: a
+ * refusal is the only terminal that carries AUTHORED text on the run row —
+ * a failure's reason is the failing step's `error`, and a completion's
+ * `successMessage` is copied from the flow definition onto the RESULT, not
+ * stored here.
+ */
+ refusalMessage: z.string().optional().describe(
+ 'Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. '
+ + 'Absent on every other status.',
+ ),
+
/** Trigger context */
trigger: z.object({
type: z.string().describe('Trigger type (e.g., "record_change", "schedule", "api", "manual")'),
diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts
index a0d5cf2688..f508660909 100644
--- a/packages/spec/src/automation/flow.zod.ts
+++ b/packages/spec/src/automation/flow.zod.ts
@@ -23,6 +23,7 @@ import { retiredKey } from '../shared/retired-key';
import { retryPolicyShape } from '../shared/retry-policy.zod';
import { strictObject } from '../shared/strict-object';
import { parseFlowNodeRegions } from './control-flow.zod';
+import { EndConfigSchema } from './builtin-node-config.zod';
export const FlowNodeAction = z.enum([
'start', // Trigger
'end', // Return/Stop
@@ -106,7 +107,11 @@ export const FLOW_STRUCTURAL_NODE_TYPES: readonly string[] = ['start', 'end'];
* Deliberately still open, both re-confirmed here rather than left to be
* rediscovered: the node `config` slot (above), and
* {@link FlowVersionHistorySchema} at the foot of this file (wire — see its own
- * note).
+ * note). One type-specific exception to the open slot (#14945): the structural
+ * `end` node has no executor and no descriptor, so the flow parse is the ONLY
+ * door its config passes through — {@link parseEndNodeConfig} applies
+ * {@link EndConfigSchema} to it. Every other type's `config` stays the
+ * executor's to close.
*/
/**
@@ -229,7 +234,43 @@ export const FlowVariableSchema = lazySchema(() => strictObject(
* any test runs. `flow-region-cycle.test.ts` pins both import orders in eager
* mode so that failure can never come back silently.
*/
-export const FlowNodeSchema = lazySchema(() => flowNodeObject().transform(parseFlowNodeRegions));
+export const FlowNodeSchema = lazySchema(() => flowNodeObject().transform(
+ (node, ctx) => parseEndNodeConfig(parseFlowNodeRegions(node), ctx),
+));
+
+/**
+ * Parse a structural `end` node's `config` against {@link EndConfigSchema}
+ * (#14945), the second half of the node transform above.
+ *
+ * Why here and not at an executor: `end` is in `FLOW_STRUCTURAL_NODE_TYPES` —
+ * the engine terminates the run on reaching it without dispatching to any
+ * executor, so neither of the two doors every other builtin's config passes
+ * through exists for it (no descriptor `configSchema` at `registerFlow()`, no
+ * execute-time `parse()`). Without this pass an `end` node carrying
+ * `{ outcome: 'refused' }` and no `message`, or a `message` no outcome would
+ * ever render, parsed clean and ran as a plain completion. Applied at the NODE
+ * level so an `end` nested in a region is checked exactly like a top-level
+ * one, and the parsed (defaulted) config is written back — `parsed` means
+ * parsed, as for regions. A node with no `config` is left without one: the
+ * default `outcome` is `completed` either way, and materialising a config
+ * block on every plain terminal would be a shape change nobody asked for.
+ *
+ * Issues are re-raised under `['config', …]`, so a flow-level parse reports
+ * them at `nodes[i].config.message` — the same address `formatZodError`
+ * prints for any other node key. A hoisted `function` for the same reason
+ * {@link flowNodeObject} is one (trap 2 above).
+ */
+function parseEndNodeConfig(node: T, ctx: z.RefinementCtx): T {
+ if (node.type !== 'end' || node.config === undefined) return node;
+ const parsed = EndConfigSchema.safeParse(node.config);
+ if (!parsed.success) {
+ for (const issue of parsed.error.issues) {
+ ctx.addIssue({ ...issue, path: ['config', ...issue.path] });
+ }
+ return node;
+ }
+ return { ...node, config: parsed.data };
+}
/**
* The plain `ZodObject` half of {@link FlowNodeSchema} — its declared keys,
diff --git a/packages/spec/src/contracts/automation-result-status.pin.test.ts b/packages/spec/src/contracts/automation-result-status.pin.test.ts
index 79594c7567..4e4a939462 100644
--- a/packages/spec/src/contracts/automation-result-status.pin.test.ts
+++ b/packages/spec/src/contracts/automation-result-status.pin.test.ts
@@ -2,14 +2,20 @@
/**
* [#14384] `AutomationResult.status` is exactly
- * `'completed' | 'paused' | 'failed' | 'stranded'`, and the wire mirror
+ * `'completed' | 'paused' | 'failed' | 'stranded' | 'refused'`, and the wire mirror
* (`TriggerFlowResponseSchema.data.status`, `api/automation-api.zod.ts`) is
- * the same four — contract half of the #13937 shape-4 ruling (maintainer
+ * the same five. Four of them are the contract half of the #13937 shape-4 ruling (maintainer
* 2026-09-01), which names the terminally-failed-but-repairable run on this
* union: a resume consumed the suspension, a downstream node threw, the run is
* recorded as failed and can be re-armed only by an explicit operator verb
* (#13909's condition). The literal is `'stranded'`.
*
+ * [#14945] The fifth, `'refused'`, is the contract half of the 2026-09-05
+ * ruling (option 2′): the run reached an `end` node declaring
+ * `outcome: 'refused'` — a successful evaluation that said no, terminal,
+ * never resumed, distinct from `'failed'`, with the rendered per-record text
+ * on the new `refusalMessage` member (mirrored on the wire the same way).
+ *
* Three things are pinned, because each drifts on its own:
*
* 1. **The union's membership, at the type level.** `status` is a TypeScript
@@ -62,6 +68,7 @@ export const AUTOMATION_RESULT_STATUSES = [
'paused',
'failed',
'stranded',
+ 'refused',
] as const satisfies readonly ContractStatus[];
/**
@@ -69,7 +76,7 @@ export const AUTOMATION_RESULT_STATUSES = [
* pin no program compiles is no pin at all (`check:test-typecheck` compiles
* this file under `tsconfig.test.json`).
*/
-export type AutomationResultStatusIsExactlyTheFour = Assert< Eq< ContractStatus, (typeof AUTOMATION_RESULT_STATUSES)[number] > >;
+export type AutomationResultStatusIsExactlyTheFive = Assert< Eq< ContractStatus, (typeof AUTOMATION_RESULT_STATUSES)[number] > >;
/** Wire ↔ contract: the Zod enum's inferred type IS the interface's union. */
export type WireStatusMatchesContract = Assert< Eq< WireStatus, ContractStatus > >;
@@ -78,7 +85,7 @@ const wireStatusEnum = TriggerFlowResponseSchema.shape.data.shape.status.unwrap(
describe('[#14384] AutomationResult.status names the stranded run', () => {
it('reads a non-empty membership (anti-vacuity)', () => {
- expect(AUTOMATION_RESULT_STATUSES.length).toBe(4);
+ expect(AUTOMATION_RESULT_STATUSES.length).toBe(5);
expect(wireStatusEnum.options.length).toBeGreaterThan(0);
});
@@ -108,7 +115,32 @@ describe('[#14384] AutomationResult.status names the stranded run', () => {
expect(parsed.data.runId).toBe('run_stranded_001');
});
- it('refuses a status outside the four, at `data.status`, as an enum violation', () => {
+ it("names the refused run 'refused' (#14945) — beside `'failed'`, never folded into it", () => {
+ expect(AUTOMATION_RESULT_STATUSES).toContain('refused');
+ expect(wireStatusEnum.options).toContain('refused');
+ expect(wireStatusEnum.options).toContain('failed');
+ });
+
+ it('a refused terminal envelope parses and is PRESERVED on the wire — status, success and the rendered message', () => {
+ // Same #13078 lesson as the stranded case above: strip-mode would drop
+ // `refusalMessage` silently, and a runner would then have nothing to
+ // show — so the value must come back out, not merely parse.
+ const parsed = TriggerFlowResponseSchema.parse({
+ success: true,
+ data: {
+ success: true,
+ status: 'refused',
+ refusalMessage: 'Refused: Acme Corp is a confirmed duplicate of Acme Corporation',
+ durationMs: 12,
+ },
+ });
+ expect(parsed.data.status).toBe('refused');
+ expect(parsed.data.success).toBe(true);
+ expect(parsed.data.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate of Acme Corporation');
+ expect(parsed.data.successMessage).toBeUndefined();
+ });
+
+ it('refuses a status outside the five, at `data.status`, as an enum violation', () => {
const result = TriggerFlowResponseSchema.safeParse({
success: true,
data: { success: false, status: 'strand' },
@@ -122,7 +154,7 @@ describe('[#14384] AutomationResult.status names the stranded run', () => {
it('the contract JSDoc names the condition beside the literal', () => {
const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
- const declaration = "status?: 'completed' | 'paused' | 'failed' | 'stranded';";
+ const declaration = "status?: 'completed' | 'paused' | 'failed' | 'stranded' | 'refused';";
const at = source.indexOf(declaration);
expect(at).toBeGreaterThan(-1);
// The doc block immediately above the declaration — from its last `/**`.
@@ -136,5 +168,14 @@ describe('[#14384] AutomationResult.status names the stranded run', () => {
expect(doc).toMatch(/explicit operator verb/i);
// And the ruling's boundary: the plugin-local label is not promoted.
expect(doc).toContain('StrandedRunState');
+ // [#14945] The refused run, in the ruling's terms: a successful
+ // evaluation that said no, distinct from failed, never resumed, rendered
+ // with Close only.
+ expect(doc).toContain("`'refused'`");
+ expect(doc).toMatch(/successful evaluation that said no/i);
+ expect(doc).toMatch(/DISTINCT from `'failed'`/);
+ expect(doc).toMatch(/never resumed/i);
+ expect(doc).toMatch(/Close only/);
+ expect(doc).toContain('refusalMessage');
});
});
diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts
index 79fc24b72a..f593d577b3 100644
--- a/packages/spec/src/contracts/automation-service.ts
+++ b/packages/spec/src/contracts/automation-service.ts
@@ -346,8 +346,20 @@ export interface AutomationResult {
* a request's run and is deliberately NOT promoted to this status (same
* ruling): it classifies WHY a request's run is unrecoverable, this names
* the run's own lifecycle verdict.
- */
- status?: 'completed' | 'paused' | 'failed' | 'stranded';
+ *
+ * `'refused'` names the run that reached an `end` node declaring
+ * `outcome: 'refused'` (#14945; maintainer ruling 2026-09-05, option 2′):
+ * a successful evaluation that said no. Terminal exactly like
+ * `'completed'`, and DISTINCT from `'failed'` on purpose — nothing threw,
+ * the flow refused deliberately, and the authored reason is rendered
+ * per-record into {@link refusalMessage}. `success` stays `true` (the
+ * evaluation succeeded), `successMessage` is NOT set (there is nothing to
+ * toast), and the run is never resumed. A runner renders `refusalMessage`
+ * with Close only — no Submit, no completion toast. This member is the
+ * ruling's contract half; the engine begins stamping it when the
+ * services half (the `end` handling in `service-automation`) lands.
+ */
+ status?: 'completed' | 'paused' | 'failed' | 'stranded' | 'refused';
/** Run id — set when `status` is `'paused'`, so callers can resume it. */
runId?: string;
/**
@@ -365,6 +377,15 @@ export interface AutomationResult {
*/
successMessage?: string;
errorMessage?: string;
+ /**
+ * #14945: the rendered refusal, set when `status` is `'refused'` — the
+ * `end` node's `message` template interpolated against the run's
+ * variables, so it names the record (`Refused: Acme Corp is a confirmed
+ * duplicate`). Authored per-record text, not a copy of a flow-level
+ * string like the two above; absent on every other status. The runner
+ * shows it with Close only.
+ */
+ refusalMessage?: string;
/**
* #4354: what the run did — records selected / acted on, gate skips,
* per-node status. Set on a TERMINAL result (a paused run has not finished