Repository navigation
Commit cca6991
feat(service-automation): the flow
Fixes #15788
Lane (2) of the #14945 maintainer ruling 2′: the flow `end` node honours
`outcome: 'refused'`.
## The contract this is built against (lane 1, already on `main`)
Re-measured on `origin/main` at `b5cbfef9c` — a card split out of a
sequenced ruling says "already landed" about the delivering branch, not
about `main`:
| declaration | where |
|---|---|
| `ExecutionStatus` member `refused` |
`packages/spec/src/automation/execution.zod.ts:38` |
| `outcome: z.enum(['completed','refused']).default('completed')` |
`packages/spec/src/automation/builtin-node-config.zod.ts:658` |
| refinement — `refused` REQUIRES a `message`; a `message` on a
`completed` end is refused as a silent no-op | same file, `:671-690` |
| `ExecutionLog.refusalMessage` | `execution.zod.ts:369` |
| `AutomationResult.status` gains `refused`, plus
`AutomationResult.refusalMessage` |
`packages/spec/src/contracts/automation-service.ts:405,430` |
| `TriggerFlowResponseSchema.data.status` / `.refusalMessage` |
`packages/spec/src/api/automation-api.zod.ts:356,382` |
⛔ `packages/spec` is untouched by this PR. Nothing in the implementation
needed a spec change — the lane-1 contract was complete, including the
`refusalMessage` run-row key and the wire members, so this lane is
purely the producer half.
Two places in the tree stated, in words, that this was the missing half,
and both are updated here rather than worked around:
- `TERMINAL_RUN_STATUSES` (engine.ts): *"`refused` is declared by the
spec but no engine path produces it today, so adding it here would
enumerate a value nothing can write."*
- `sys_automation_run.status` (the object): *"`refused` is deliberately
ABSENT: `ExecutionStatus` declares it (#14945) but no engine path
produces it, and an option nothing can write is a declared-but-inert
value (ADR-0078)."*
The engine now produces it, so the writer, the reader's row gate and the
stored column widen **in this one change** — which is the condition
those notes set, not an exception to it.
## What changed
- **`executeNode`'s `end` branch.** It opened with `if (node.type ===
'end') return;` — the whole defect: a structural node with no executor
and no descriptor, so this was the only place the outcome could be read
and nothing read it. It now reads the PARSED config (lane 1's
`parseEndNodeConfig` runs inside `FlowNodeSchema`, regions included, so
`outcome` is defaulted and a `refused` with no `message` was already
refused at the flow parse — no second door, no `??`, no re-parse) and
throws a `FlowRefusalSignal`, the twin of the existing
`FlowSuspendSignal`.
- **One terminal shape, three producers.** `finishRefusedRun` is called
from `execute()`, `resumeInternal` and `executeWithoutRetry`. That
chokepoint is not stylistic: this exact file lost `successMessage`
(#9414) and the durable pause (#9510) by implementing them at one exit
and not the others, which made a run's outcome a function of its ROUTE.
A triggered run, a resumed screen flow and an attempt under `strategy:
'retry'` now answer identically.
- **The envelope.** `success: true` (a refusal is a successful
evaluation that says no), `status: 'refused'`, `refusalMessage`, no
`error`, no `errorMessage`, ⛔ no `successMessage`, ⛔ no `runId`. The
retry ladder stops on it without a new branch — `retryExecution` already
reads `result.success` as "this attempt did not fail, stop retrying",
which is the true sentence here; ⛔ a refusal must never consume retry
budget.
- **Persistence.** `RunRecord.refusalMessage` + a new
`sys_automation_run.refusal_message` column, written always (NULL
included — `recordTerminal` is an upsert, so a rewritten row must CLEAR
a refusal it no longer carries) and read back through `loadTerminal` /
`runRecordToLogEntry`. ⛔ Not folded into `error`: text in `error` tells
every reader — an operator, the Runs surface, a sweep filtering `error
IS NOT NULL` — that the run broke. Same reason #15223 stopped folding
`cancelled` into `failed`.
- **Never resumed.** A refusal writes no continuation, so `resume()`
answers `RUN_NOT_FOUND`.
- **`packages/plugin-approvals`, comment only.** Its
`RUN_STATUS_LIVENESS` docblock said the service-automation vocabulary
*"excludes `refused` on purpose"*. This PR makes that false, so the
paragraph is corrected in the same change. No behaviour moves: that map
already classified `refused` as terminal, and it stays the authority for
its own sweep.
### The region boundary, made loud
An `end` declaring `outcome: 'refused'` **inside a structured region**
is refused with a named message, at the same line where `runRegion`
already refuses a durable pause. Left to propagate, the signal would
unwind into the `try_catch` executor's own `catch (err)`, which reads
every throw as the try region FAILING — so an author's refusal would run
the error path and the run would still record `completed`: the
pre-#15788 silence with an extra step.
⛔ Nothing an author had is narrowed: before this PR an `end` in a region
was a no-op whatever its `outcome`, so the shape being made loud has
never once been honoured. Whether a refusal should instead PROPAGATE out
of a region and terminate the run is a real question and ⛔ not one this
lane rules on — the #14945 ruling says nothing about regions, and
"prefer failing to falling back" decides the interim.
## Before / after
Reproduce first: the pins were written and committed (`f525fb87f`)
**before** any engine edit, and run against the unfixed tree.
| run | command | result |
|---|---|---|
| BEFORE (unfixed engine) | `vitest run
src/end-node-refused-outcome.test.ts` | **7 failed, 4 passed (11)** |
| AFTER | same file, + the region pin | **12 passed (12)** |
| AFTER | `pnpm --filter @objectstack/service-automation test` | **135
files, 1597 tests, all passing** |
| AFTER | `pnpm --filter @objectstack/plugin-approvals test` | **45
files, 738 tests, all passing** |
The BEFORE failures were the shape of the defect, not of a broken
harness:
```
AssertionError: expected undefined to be 'refused'
AssertionError: expected undefined to be 'Refused: Acme Corp is a confirmed dup…'
AssertionError: expected 'completed' to be 'refused'
```
The 4 that passed BEFORE are the fences, green on both sides on purpose:
a plain `end` still completes with `successMessage`, an explicit
`outcome: 'completed'` is the same completion, a genuinely failed run
still reads `failed` (the discriminating control — without it, "the
refusal path reads `refused`" would be consistent with an engine that
had started calling everything `refused`), and a refused run was already
not resumable because it writes no continuation.
## The interpolation is the screen-`description` one, and that is pinned
by mechanism
The ruling: the refusal message goes through *the same interpolation a
screen `description` gets* — one implementation, ⛔ never a second
template engine.
The implementation is `interpolate()` in
`packages/services/service-automation/src/builtin/template.ts`, reached
through the four-line coercion that `screen-nodes.ts` held in a local
`interp` closure and read at `screen.description` (`screen-nodes.ts:195`
and `:279`). Those four lines are hoisted verbatim to `template.ts` as
`interpolateText`; `interp` now delegates to it and the refusal path
calls the same function. Same bytes in, same bytes out — the only change
is where the lines live.
Asserting "it substitutes `{record.name}`" would be far too weak, so the
pin drives **both slots** with six templates whose behaviour is specific
to this interpolator and compares the two renderings for **equality**:
dotted-path walk, numeric array indexing, the `{$User.Id}` context
token, the CEL-mirrored numeric stdlib (`{round(x)}`, #11060), an
unresolvable embedded token rendering as empty string, and an
object-valued token JSON-serialized rather than `[object Object]`
(#3450).
That pin's ability to FAIL is measured below, not assumed.
## Reverse verification
Both legs mutate the **committed** tree, prove the mutation reached disk
before reading any result, restore with `git checkout HEAD -- path`, and
prove byte identity by blob hash. Both scripts carry `trap … EXIT INT
TERM` with absolute paths; the trap is the crash convenience, the hash
compare is the proof.
**Leg 1 — remove the refusal branch (the fix itself).**
```
HEAD blob : 86fede4
anchor count before : 1 inject count before : 0
anchor count after : 0 inject count after : 1
on-disk mutated : 1d64fe18affedec59afaa230a4b7d92cf4c21951 (differs — not a no-op)
ABLATED TEST EXIT : 1
Tests 8 failed | 56 passed (64)
on-disk restored : 86fede4 RESTORE: byte-identical to HEAD
git diff HEAD / git status --porcelain : empty
```
Predicted direction before running: RED. The 8 that reddened are exactly
the assertions about the new behaviour — the 6 defect pins, the
interpolator-equality pin and the region pin. The set is the right one
in both directions:
- The 4 fences stayed green, because none of them depends on the branch
that was removed. A fence that reddened here would mean the change had
reached something the ruling names ⛔ do not touch.
- All 52 `suspended-run-store.test.ts` cases stayed green, **including
the 4 new `refusal_message` ones**, and that is correct rather than a
gap: they drive `ObjectStoreSuspendedRunStore` with a `RunRecord`
directly and never enter `executeNode`. They pin the persistence layer;
leg 1 mutated the producer.
- The mutation was on `src/`, and the test file imports `./engine.js`
from inside the same package, so vitest resolves it from source. The RED
itself is the proof of that resolution path — a stale-`dist` reading
would have stayed green.
**Leg 2 — can the "one interpolator" pin actually fail?** The refusal
path's `interpolateText(…)` call was replaced with a plausible second
template engine (a naive `{token}` substitution with dotted-path support
— the kind a reviewer waves through).
```
anchor count after : 0 inject count after : 2
on-disk mutated : 79dd7a425bce3252d526de885b1c8826451e089c (differs)
SECOND-ENGINE TEST EXIT : 1
× renders a refusal `message` byte-identically to a screen `description`
AssertionError: refusal message for by {$User.Id}: expected 'by ' to be 'by usr_7'
Tests 1 failed | 11 passed (12)
on-disk restored : 86fede4 RESTORE: byte-identical to HEAD
```
The second engine passed the obvious `{record.name}` probe and was
caught at the context token — which is the whole reason the probe set is
six templates and not one.
## Clause-② re-derivation, from the DELIVERED diff
**`Clause-②: yes`** — which is what the claim predicted, re-derived here
from the built output rather than inherited.
The test is reachability from the published entry (`index.ts` re-exports
plus the package's `exports`/`files`) plus any new key on a published
payload. `@objectstack/service-automation` publishes
`["dist","README.md","CHANGELOG.md"]` with one entry,
`./dist/index.d.ts`. After `pnpm --filter
@objectstack/service-automation build`:
| carrier | evidence |
|---|---|
| `RunRecord` gains `refusalMessage?: string` | `dist/index.d.ts:743`,
and `type RunRecord` is in the entry's export list — a NEW KEY on an
already-published payload, the mandatory `yes` |
| `TerminalRunStatus` widens 4 members to 5 | `dist/index.d.ts:694-696`,
`type TerminalRunStatus` exported; the runtime value ships too
(`dist/index.js`: `TERMINAL_RUN_STATUSES =
["completed","failed","cancelled","timed_out","refused"]`) |
| `SysAutomationRun` gains the `refusal_message` field and the `refused`
option | `dist/index.d.ts:9339`, `SysAutomationRun` exported as a value
|
⛔ The terminal status is NOT what carries it: `refused` was already a
declared `ExecutionStatus` member, so `status` is an existing key taking
a newly-legal value. The carrier is the new key.
**Discriminating controls**, so the probe is not just reporting
"everything in my diff is published":
| probe | hits in `dist/index.d.ts` | reads as |
|---|---|---|
| `interpolateText` — new in this diff, internal to `builtin/` | **0** |
in the diff, NOT published |
| `isTerminalRunStatus` — exported from `engine.ts`, not re-exported
from the barrel | **0** | exists in source, NOT on the published surface
|
| `RunRecord` — published before this diff | 18 | positive control: the
probe can see published symbols |
| `zzzNotASymbol` | 0 | negative control |
A probe that answered ">0" for `interpolateText` and
`isTerminalRunStatus` would have been measuring file text rather than
the published surface. It did not.
The changeset is graded **`minor`**, as the ruling grades this lane —
never `patch`.
## Gates
Derived mechanically from the real change set, not from the dispatch
list, and reconciled with `--ran`:
```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
→ 9 path(s) vs merge base 3aaea38 · 65 command(s) derived
node scripts/pm/dispatch-gates.mjs --ran ran.txt --repo objectstack-ai/objectstack
→ Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
EXIT CODES — all 65 accounted famil(ies) carry one, so the NOT-MEASURED count
above is DERIVED from them.
```
**Denominator: 65 derived / 65 run / 0 NOT MEASURED / 0 UNRUN.** Three
needed a prerequisite the first pass did not have and were re-run after
clearing it, ⛔ not recorded as failures:
- `check:dual-build-cjs-loads` and `check:i18n` exited **3 —
PREREQUISITE NOT MET** ("nothing was measured") until `turbo run build
--filter='./packages/*' --filter='./packages/*/*'`; both **exit 0**
after.
- `check:type-check-debt` exited 3 twice: once for the same unbuilt
closure, then on an OOM at the `--max-old-space-size=4096` this
container prefixes onto heavy commands — the gate prints that ceiling
itself ("the caller's NODE_OPTIONS, which is tighter"). Re-run without
the tightened cap: **exit 0** — `76/80 workspace packages type-checked,
5 ledger entries re-measured, 55 raw tsc errors, none above its recorded
number`.
Also run, beyond the derived set:
- `pnpm --filter @objectstack/service-automation typecheck` — exit 0,
and its `check:test-typecheck` leg compiles the test layer, so the new
pins are type-checked rather than merely executed.
- **Consumer sweep, downstream direction** (`--filter
'...@objectstack/service-automation'`, the direction a contract widening
lands in): **18 packages** typecheck green — `cli`, `client`,
`client-react`, the four `connectors/*`, `plugin-approvals`, both
`triggers/*`, `verify`, `qa/dogfood`, `qa/downstream-contract`, the four
`examples/*`, and `service-automation` itself.
- `pnpm lint` (= `eslint . --no-inline-config`) over the **whole repo**:
**exit 0**, no findings. Run at `60ca35a2f`, after the final commit — no
narrowing to justify.
`origin/main` was merged at `3aaea3879` through
`scripts/pm/os-regen-merge.sh`; it left no generated artifact to
regenerate and no `os-regen-pending` deferral, and `pnpm install
--frozen-lockfile` was re-run afterwards per the stale-artefact rule.
## Acceptance notes
Out of scope, noted rather than filed — each names who would meet it:
- **A `subflow` CHILD that refuses is rolled up as an ordinary success
by its parent.** `subflow-node.ts` branches only on `child.status ===
'paused'`, so a refused child returns `success: true` and the parent
walks on. ⛔ Not a regression this PR introduces — the parent behaved
identically when a refusing child simply completed — but the ruling does
not say what a parent should do with a child's refusal, and it is a live
question the moment authors start writing them. Carrier: the #14945
ruling seat, or whoever takes lane 3.
- **`plugin.ts` describes the retention scope as `{ status: { $in:
['completed', 'failed'] } }` in two comments** (`:138`, `:787`) while
the object has declared four members since #15223 and five as of this
PR. Pre-existing staleness, ⛔ not made false by this change, and left
alone to keep the diff at the vocabulary it is actually widening.
Carrier: the next PR to touch `sys_automation_run`'s retention.
- The naming hazard the triage seat measured is real and is answered in
code rather than restated: `refused` in this package overwhelmingly
means a GUARD refusal (the engine declining to execute — a failure), and
this lane's `refused` means a successful evaluation that said no. The
disambiguation is written at both definition sites (`FlowRefusalSignal`,
and the pin file's header) so a future `grep` reads the sense at the
site rather than from the word.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>end node honours outcome: 'refused' — a terminal refused run, distinct from failed (#18109)1 parent 7c7e76f commit cca6991
9 files changed
Lines changed: 964 additions & 38 deletions
File tree
- .changeset
- packages
- plugins/plugin-approvals/src
- services/service-automation/src
- builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
Lines changed: 10 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
354 | 354 | | |
355 | 355 | | |
356 | 356 | | |
357 | | - | |
| 357 | + | |
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
362 | | - | |
363 | | - | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
364 | 370 | | |
365 | 371 | | |
366 | 372 | | |
| |||
Lines changed: 8 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
170 | | - | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
171 | 173 | | |
172 | 174 | | |
173 | 175 | | |
| |||
Lines changed: 30 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
353 | 353 | | |
354 | 354 | | |
355 | 355 | | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
356 | 386 | | |
357 | 387 | | |
358 | 388 | | |
| |||
0 commit comments