Skip to content

Commit fe4ba76

Browse files
feat(spec): the flow end node declares its outcome — refused with an interpolated message (wip)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
1 parent c99449a commit fe4ba76

10 files changed

Lines changed: 644 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`.
6+
7+
Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type.
8+
9+
The contract, declared here first (the engine and runner halves follow in their own packages):
10+
11+
- **`end` node config**`EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was.
12+
- **Run row**`ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run.
13+
- **Result / wire**`AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast.
14+
15+
Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before.

content/docs/automation/flows.mdx

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

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

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

packages/spec/src/api/automation-api.zod.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,16 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext
340340
// `stranded` is the contract half of the #13937 shape-4 ruling (#14384);
341341
// the condition is #13909's. Mirrors `AutomationResult.status` member for
342342
// member — the pin is `contracts/automation-result-status.pin.test.ts`.
343-
status: z.enum(['completed', 'paused', 'failed', 'stranded']).optional().describe(
343+
// `refused` (#14945) mirrors the same way: the run reached an `end` node
344+
// declaring `outcome: 'refused'` — terminal, never resumed, distinct from
345+
// `failed`; its rendered text rides on `refusalMessage` below.
346+
status: z.enum(['completed', 'paused', 'failed', 'stranded', 'refused']).optional().describe(
344347
'Lifecycle status. `paused` means the run suspended at a node and can be '
345-
+ 'continued with the resume route. Absent or `completed`/`failed`/`stranded` '
346-
+ 'means the run reached a terminal state. `stranded` is the '
348+
+ 'continued with the resume route. Absent or `completed`/`failed`/`stranded`/`refused` '
349+
+ 'means the run reached a terminal state. `refused` is a first-class refusal: the '
350+
+ 'flow reached an `end` node declaring `outcome: \'refused\'` — a successful evaluation '
351+
+ 'that said no, so `success` is true, `successMessage` is absent and the per-record '
352+
+ 'reason is on `refusalMessage`; a runner shows it with Close only. `stranded` is the '
347353
+ 'terminally-failed-but-repairable run: a resume consumed the suspension '
348354
+ 'and a downstream node threw, so the run is recorded as failed and can be '
349355
+ 're-armed only by an explicit operator verb - never by the resume route, '
@@ -363,6 +369,12 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext
363369
errorMessage: z.string().optional().describe(
364370
'Friendly terminal message copied from the flow definition on failure',
365371
),
372+
refusalMessage: z.string().optional().describe(
373+
'Rendered refusal, set when `status` is `refused` - the `end` node\'s `message` '
374+
+ 'template interpolated against the run\'s variables, so it names the record. '
375+
+ 'Authored per-record text (not a flow-level copy like the two above); absent on '
376+
+ 'every other status. A runner shows it with Close only',
377+
),
366378
summary: FlowRunSummarySchema.optional().describe(
367379
'What the run did - records selected / acted on, gate skips, per-node '
368380
+ 'status. Set on a TERMINAL result (a paused run has not finished doing '

packages/spec/src/automation/builtin-node-config.zod.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
*
66
* Config contracts for the remaining flat builtins — the CRUD quartet
77
* (`get_record` / `create_record` / `update_record` / `delete_record`),
8-
* `screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
8+
* `screen`, `map` (#4045), since #14149 `assignment`'s value contract and,
9+
* since #14945, the structural `end` node's outcome (`EndConfigSchema`, the
10+
* one contract here the FLOW PARSE applies rather than an executor).
911
* Sibling of `io-node-config.zod.ts` (notify / http) and `control-flow.zod.ts`
1012
* (loop / parallel / try_catch).
1113
*
@@ -475,6 +477,118 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({
475477
export type ScreenConfig = z.input<typeof ScreenConfigSchema>;
476478
export type ScreenConfigParsed = z.infer<typeof ScreenConfigSchema>;
477479

480+
// ─── end ─────────────────────────────────────────────────────────────
481+
482+
/**
483+
* `end` node config — how the run ENDS (#14945).
484+
*
485+
* `end` is a structural node (`FLOW_STRUCTURAL_NODE_TYPES`): the engine
486+
* terminates the run on reaching it with no registered executor, so — unlike
487+
* every other contract in this module — nothing `parse()`s this shape at
488+
* execute time and no descriptor `configSchema` closes it at `registerFlow()`.
489+
* The one door an `end` node's config passes through is the flow parse itself,
490+
* which is why `FlowNodeSchema` (flow.zod.ts) applies this contract to every
491+
* `type: 'end'` node it parses, at any region depth, and writes the parsed
492+
* (defaulted) config back.
493+
*
494+
* ## `outcome` — the terminal state the run records
495+
*
496+
* Every terminal of a flow used to be "completed": a flow could say *do this*
497+
* but not *refuse this, and say why, for which record*. The only channel that
498+
* interpolated per-record text was a `screen` node's `description`, and a
499+
* message-only screen is an INPUT step wearing a notice's clothes — it renders
500+
* Submit, and submitting resumes the run to `end`, whose runner toasts
501+
* `Flow "…" completed` at a user who was just told "this is refused" (the
502+
* hotcrm lead-conversion refusal, hotcrm#1288 / hotcrm#1555, is the measured
503+
* case).
504+
*
505+
* Maintainer ruling 2026-09-05 (option 2′): the refusal is a first-class
506+
* OUTCOME of the existing terminal node, not a second terminal node type —
507+
* `outcome: 'refused'` with an interpolated `message`. A refused end is a
508+
* terminal state: the run records `refused` (`ExecutionStatus`, distinct from
509+
* `failed` — a refusal is a successful evaluation that says no) together with
510+
* the rendered message (`ExecutionLog.refusalMessage`), it is never resumed,
511+
* and a runner renders the message with Close only — no Submit, no completion
512+
* toast; the invoking action's `successMessage` stays suppressed. The halves
513+
* land in sequence: this contract, then the engine's `end` handling (stamping
514+
* the outcome, persisting the rendered message), then the runner.
515+
*
516+
* ## `message` — required by a refusal, refused by a completion
517+
*
518+
* `message` is a `{token}` template rendered at the engine's existing
519+
* interpolation points, exactly as a `screen` node's `description` is —
520+
* `'Refused: {record.name} is a confirmed duplicate'` yields per-record text at
521+
* run time. Two refinements keep the pair honest, in both directions:
522+
*
523+
* - `outcome: 'refused'` with no `message` is REFUSED — a refusal without
524+
* text is exactly the shape this contract exists to make expressible, and
525+
* an author who omits it ships a refusal nobody can explain.
526+
* - `message` with `outcome: 'completed'` (or omitted) is REFUSED — a
527+
* completion renders nothing, so the key would be a silent no-op: the kind
528+
* of key an AI author sets, sees no error for, and reports "done" over.
529+
*/
530+
export const EndConfigSchema = lazySchema(() => strictObject({
531+
surface: 'this end node config',
532+
history:
533+
'Until this shape was declared, an `end` node had no config contract at all — any key was accepted at parse '
534+
+ 'and ignored at run time, so a refusal an author wrote here shipped as a plain completion.',
535+
aliases: {
536+
// The words a refusal arrives spelled in — `reason` / `text` from prose,
537+
// `description` from the screen node an author migrates the refusal OUT
538+
// of (the card's own workaround), `status` / `result` for the outcome.
539+
reason: 'message',
540+
text: 'message',
541+
description: 'message',
542+
status: 'outcome',
543+
result: 'outcome',
544+
},
545+
guidance: {
546+
title:
547+
'An `end` node has no heading — a runner shows `message` alone under the flow\'s label. Put the text in '
548+
+ '`message`; a `title` belongs to a `screen` node.',
549+
},
550+
}, {
551+
/** Terminal state the run records: `completed` (default) or `refused`. */
552+
outcome: z.enum(['completed', 'refused']).default('completed').describe(
553+
'How the run ends when it reaches this node. `completed` (the default) is the ordinary terminal. '
554+
+ '`refused` is a first-class refusal: the run records `refused` — distinct from `failed`, a refusal is a '
555+
+ 'successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows '
556+
+ 'the message with Close only: no Submit, no completion toast.',
557+
),
558+
/** Why the run was refused. Interpolates `{token}` like a screen `description`. */
559+
message: z.string().min(1).optional().describe(
560+
'Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen '
561+
+ '`description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; '
562+
+ 'refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op.',
563+
),
564+
}).superRefine((config, ctx) => {
565+
if (config.outcome === 'refused') {
566+
if (config.message === undefined) {
567+
ctx.addIssue({
568+
code: 'custom',
569+
path: ['message'],
570+
message:
571+
"`outcome: 'refused'` requires a `message` — a refusal with no text is the shape this contract exists to "
572+
+ 'replace (a screen pretending to be a notice). Say why, as a `{token}` template so the text names the '
573+
+ "record: `message: 'Refused: {record.name} is a confirmed duplicate'`.",
574+
});
575+
}
576+
return;
577+
}
578+
if (config.message !== undefined) {
579+
ctx.addIssue({
580+
code: 'custom',
581+
path: ['message'],
582+
message:
583+
"`message` is only rendered when `outcome` is 'refused' — on a completed end nothing shows it, so the key "
584+
+ "would be a silent no-op. Either set `outcome: 'refused'` or delete `message`.",
585+
});
586+
}
587+
}));
588+
589+
export type EndConfig = z.input<typeof EndConfigSchema>;
590+
export type EndConfigParsed = z.infer<typeof EndConfigSchema>;
591+
478592
// ─── map ─────────────────────────────────────────────────────────────
479593

480594
/**

0 commit comments

Comments
 (0)