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