fyflow-orchestration 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -26,7 +26,7 @@ have: **sequence**, **decision routing**, **fan-out** and **join**.
26
26
  a worker declares what it expects and what it returns — it need not, and `unchecked` is the verdict
27
27
  when it does not — `checkWiring` gives every arc of a flow one of five verdicts from those
28
28
  declarations, `tryTransform` runs a candidate expression on a producer's own
29
- declared example and holds the result to the consumer's schema, and `signatureOf` turns the same
29
+ declared example — or on a value you give it — and holds the result to the consumer's schema, and `signatureOf` turns the same
30
30
  schemas into the TypeScript a worker's author writes against. **Section 15 is a session that does all
31
31
  three on a real miswire and then runs the flow it repaired** — it is executed by `npm run gate` like
32
32
  everything else here.
@@ -42,7 +42,7 @@ everything else here.
42
42
  and later install it for you — and the JSR package maps it to `@fyflow/scheduler` at the same
43
43
  version. The driver is written against that version's event shapes, and the range is exact because
44
44
  there is no upstream changelog to read a wider one from. **You still pass your own scheduler in**:
45
- `SchedulerLike` is a two-method structural interface, a real `FyflowScheduler` satisfies it as it is,
45
+ `SchedulerLike` is a structural interface — two methods and an optional `cancel` (section 12) — a real `FyflowScheduler` satisfies it as it is,
46
46
  and the one thing the library builds itself is a `FyflowTask` when you omit `createTask`. *(This
47
47
  paragraph read "does not depend on the scheduler … install whichever version you like" from W9 to
48
48
  W57, while section 12 said to pin it — two sentences a consumer reads first, contradicting each
@@ -79,8 +79,7 @@ import { createRuntime } from 'fyflow-orchestration/scheduler';
79
79
  import { type FlowDefinition, type RegistryDocument } from 'fyflow-orchestration';
80
80
 
81
81
  // THE REGISTRY — what exists. One worker module, and one node type that runs on it. This is data:
82
- // in your program it is a JSON file you import. The worker url is relative, and `base` below says
83
- // to what; the file itself is `examples/getting-started/worker.ts` — a class with a `run` method.
82
+ // in your program it is a JSON file you import. The worker url is relative to `base` below.
84
83
  const registry: RegistryDocument = {
85
84
  workers: {
86
85
  greeter: {
@@ -92,9 +91,7 @@ const registry: RegistryDocument = {
92
91
  };
93
92
 
94
93
  // THE FLOW — what happens. Two nodes of that type, each with its own `settings`, then an end. A flow
95
- // names registry KEYS and nothing else — no file, no url, no class — so it is portable across
96
- // runtimes that agree on the keys. The end node's `outcome` is a LABEL of yours that the driver does
97
- // not interpret; the run's own outcome is the fold's — `state.outcome` below.
94
+ // names registry keys only (no files, urls or classes), so it is portable between runtimes.
98
95
  const flow: FlowDefinition = {
99
96
  flowId: 'hello', version: 1, entry: 'greet',
100
97
  nodes: [
@@ -104,29 +101,22 @@ const flow: FlowDefinition = {
104
101
  ],
105
102
  };
106
103
 
107
- // ONE call builds the pool, the scheduler and everything between, and refuses every defect in the
108
- // REGISTRY at construction, by name. It never sees the flow: a defect THERE is refused where the
109
- // flow arrives, at `start`, which is what builds the `Driver` — so a `settings` value the worker's
110
- // schema rejects names its node from the call three lines below, not from this one. `base` is what
111
- // relative worker urls resolve against — `import.meta.url` when the registry sits beside this file.
104
+ // ONE call builds the pools and the scheduler, and refuses a defective registry here, by name. A
105
+ // defect in the FLOW is refused by `start`, below.
112
106
  const runtime = createRuntime(registry, {}, { base: import.meta.url });
113
107
 
114
- // `start` hands back `{ driver, done }` — the driver already running, and `done` a promise that
115
- // resolves with the run's terminal `RunState` (ADR 0058). **It never rejects**: `failed`, `stuck`
116
- // and `stopped` are terminal OUTCOMES, so a run that goes wrong arrives as `state.outcome` and not
117
- // as a `catch`. This snippet wants only the end, so it names only `done`. *A run with a person in
118
- // it does not end by itself — section 7 is where that is taught, and section 8 is what `done` waits
119
- // for.*
108
+ // `start` runs the flow. `done` resolves with the run's final state and never rejects: a run that
109
+ // goes wrong arrives as `state.outcome` ('failed', 'stuck', 'halted'), not as a `catch`.
120
110
  const { done } = runtime.start(flow, { text: 'hello' });
121
111
  const state = await done;
122
112
  await runtime.shutdown();
123
113
 
124
- // What the run did, read off its state: `greet` turned `hello` into `hello world`, and `shout` was
125
- // handed THAT as its input — the previous node's result is the next node's `input`.
114
+ // The previous node's result is the next node's input: `greet` made `hello world`, `shout` added `!`.
126
115
  const shout = Object.values(state.nodes).find((n) => n.nodeId === 'shout');
127
116
  console.log(state.outcome); // 'completed'
128
117
  console.log(shout?.result); // { text: 'hello world !' }
129
118
 
119
+ // Verified by the docs gate:
130
120
  if (state.outcome !== 'completed') throw new Error(`expected completed, got ${state.outcome}`);
131
121
  if (JSON.stringify(shout?.result) !== '{"text":"hello world !"}') throw new Error(`shout returned ${JSON.stringify(shout?.result)}`);
132
122
  ```
@@ -143,17 +133,74 @@ their schemas buy you before a run**.
143
133
 
144
134
  ## 3. The flow definition
145
135
 
146
- **Six** node kinds. A definition is JSON: no functions, so it can be stored, versioned and sent over
136
+ **Seven** node kinds. A definition is JSON: no functions, so it can be stored, versioned and sent over
147
137
  a wire.
148
138
 
139
+ > **`ask` is the seventh, and it is a node that WAITS FOR A PERSON, by design** — `spec/flow-definition.md` v0.42,
140
+ > [ADR 0108](doc/decisions/0108-an-ask-is-a-seventh-node-kind-with-a-task-id-and-no-dispatch.md),
141
+ > [W179](doc/work-items/w179-the-ask-node-in-the-definition.md). It runs NO worker and nothing is ever handed to the
142
+ > scheduler for it, yet it has a TASK ID (`r1:approve:-:0`, one per map item) — the address the person's answer will
143
+ > be given to — and the answer is its OUTPUT: a later node references it by `needs`, and its edges route as a task's
144
+ > settlement does, `on: ['failed']` being the person DECLINING and a `when` reading `outcome.result`, the answer.
145
+ > `settings` is opaque (the question, a form hint — the library never reads it), `answerSchema` is what a `done`
146
+ > answer must satisfy, held to the schema subset at construction (`FD-V48`), and `needs` composes what reaches it.
147
+ > It stands wherever a task may — the entry, a fan-out's branch entry, a map's branch. **What a task's WORKER reads it
148
+ > may not carry**: `type`, `pool`, `optional`, `retryPolicy`, `workerGroups` and `transform` are each refused by
149
+ > `FD-V47`, one finding per key. **A run PARKS at one** ([W180](doc/work-items/w180-asking-and-answering-in-a-run.md), [ADR 0109](doc/decisions/0109-an-ask-parks-with-nothing-dispatched-and-its-answer-is-its-result.md), section 7): nothing is handed to the
150
+ > scheduler, the node reads `suspended` with what reached it KEPT as `input`, the run `awaiting_action`, and the
151
+ > person's answer is `driver.resolve(taskId, 'done' | 'failed', …)` — a `done` answer held to `answerSchema`, a
152
+ > `failed` one the person declining. *(Until W180 every `Driver` refused a run where a branch entered an ask, by name.)*
153
+ > **BREAKING in 0.8.0** (P35 decision 8): `FlowNode` gains `AskNode`, so a `switch` exhaustive over `node.kind` no
154
+ > longer compiles until it names `'ask'`.
155
+
156
+ ```ts run
157
+ import { validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
158
+
159
+ const review: FlowDefinition = {
160
+ flowId: 'review', version: 1, entry: 'draft',
161
+ nodes: [
162
+ { id: 'draft', kind: 'task', type: 'Draft', edges: [{ to: 'approve' }] },
163
+ // An ASK: no worker, no dispatch — and a task id, which is where the answer goes.
164
+ { id: 'approve', kind: 'ask',
165
+ settings: { question: 'Publish this draft?' }, // opaque: the library never reads it
166
+ answerSchema: { type: 'object', required: ['approved'], properties: { approved: { type: 'boolean' } } },
167
+ needs: { draft: 'draft' }, // what reaches it — what the person is shown
168
+ edges: [
169
+ { on: ['failed'], to: 'declined' }, // the person DECLINED
170
+ { when: { path: 'outcome.result.approved', op: 'eq', value: true }, to: 'publish' },
171
+ { to: 'rework' }, // the default, as on a task
172
+ ] },
173
+ // The answer is the ask's OUTPUT: `needs` reaches it as it reaches a task's result.
174
+ { id: 'publish', kind: 'task', type: 'Publish', needs: { text: 'draft', answer: 'approve' }, edges: [{ to: 'done' }] },
175
+ { id: 'rework', kind: 'end', outcome: 'rework' },
176
+ { id: 'declined', kind: 'end', outcome: 'declined' },
177
+ { id: 'done', kind: 'end', outcome: 'completed' },
178
+ ],
179
+ };
180
+ const registry = { predicates: [], workers: { w: { url: 'worker.js' } }, nodeTypes: { Draft: { worker: 'w' }, Publish: { worker: 'w' } } };
181
+ const clean = validateDefinition(review, registry);
182
+ if (clean.length) throw new Error(`the ask flow should be valid: ${clean.map((f) => f.rule).join()}`);
183
+
184
+ // A task turned into an ask with its worker keys left on: FD-V47, ONE finding per key — never FD-V17.
185
+ const leftOn = { ...review, nodes: review.nodes.map((n) => (n.id === 'approve' ? { ...n, type: 'Draft', optional: true } : n)) } as unknown as FlowDefinition;
186
+ const v47 = validateDefinition(leftOn, registry).map((f) => `${f.rule}@${f.at}`);
187
+ if (v47.join() !== 'FD-V47@approve,FD-V47@approve') throw new Error(`expected two FD-V47 at approve, got ${v47.join()}`);
188
+
189
+ // An answerSchema outside the schema subset is FD-V48 — refused, never ignored.
190
+ const pattern = { ...review, nodes: review.nodes.map((n) => (n.id === 'approve' ? { ...n, answerSchema: { type: 'string', pattern: '^y' } } : n)) } as unknown as FlowDefinition;
191
+ const v48 = validateDefinition(pattern, registry).map((f) => f.rule);
192
+ if (v48.join() !== 'FD-V48') throw new Error(`expected FD-V48, got ${v48.join()}`);
193
+ ```
194
+
149
195
  > **`gate` is the sixth, and since [W93](doc/work-items/w93-what-a-gate-does-in-a-run.md) a `Driver` RUNS
150
196
  > one** — `spec/flow-definition.md` v0.23-v0.24,
151
197
  > [ADR 0066](doc/decisions/0066-a-gate-is-a-decision-with-memory-that-names-a-registered-type-and-wires-every-route-exactly-once.md)
152
198
  > and [ADR 0067](doc/decisions/0067-a-gate-runs-in-the-driver-its-memory-is-the-drivers-and-its-decision-is-recorded.md),
153
- > [W92](doc/work-items/w92-what-a-gate-is.md). A gate is a decision WITH MEMORY: it routes the branch that
154
- > reaches it by what the run's other branches have already shown it — `distinct`, the one built-in type,
155
- > sends the FIRST branch per key down its default route `first` and every later one down `duplicate` — and it
156
- > hands on exactly what reached it. The driver evaluates it where a branch reaches it — the gate's memory is
199
+ > [W92](doc/work-items/w92-what-a-gate-is.md). A gate routes the branch that reaches it by a registered TYPE
200
+ > and hands on exactly what reached it. Three types are built in: `distinct` decides WITH MEMORY — by what the
201
+ > run's other branches have already shown it, the FIRST branch per key down its default route `first` and every
202
+ > later one down `duplicate` — and since v0.31 `if` and `switch` decide WITHOUT any, by a declared condition and a
203
+ > declared table of values (section 19). The driver evaluates it where a branch reaches it — the gate's memory is
157
204
  > ONE per gate per RUN, the driver's, rebuilt by replay on a resume — records each decision as a `gated` fold
158
205
  > input (`RunState.gated`), and stops the run with an `UndeclaredRoute` if a type returns a route it does not
159
206
  > declare (`FD-R5`). *(Until W93 `new Driver` refused a flow holding one, at construction.)* Every route its
@@ -240,13 +287,14 @@ if (!noGroups.some((f) => f.rule === 'FD-V31' && f.at === 'score')) {
240
287
  | kind | | refused if |
241
288
  |---|---|---|
242
289
  | `task` | runs a worker — of its node **`type`**, REQUIRED since v0.19, on the type's default pool or a named **`pool`** instance of it, with per-node **`settings`** and optionally a **`transform`** applied to its input before the worker sees it (section 13). Has `edges`, and optionally **`needs`**, **`retryPolicy`** and **`workerGroups`** — what it says about its own task, below | it has no `edges`, or **no edge of it is eligible for `done`** (`FD-V7`); it has no `type` (`FD-V23`) — `workerType` is retired and is an undeclared key like any other (`FD-V17`); its `retryPolicy` is not exactly `{ maxRetries, backoffMs }` — an integer >= 0 and a finite number >= 0, no other key (`FD-V30`); its `workerGroups` is not a non-empty array of distinct ids the registry's `groups` declares (`FD-V31`); its `type` is not a registered node type whose worker the registry declares (`FD-V32`); its `settings` is not a plain object the type's worker `settingsSchema` accepts (`FD-V33`); its `transform` names a language the registry does not (`FD-V35`), an expression that language's compiler refuses (`FD-V36`), or is not `{ lang, expr }` (`FD-V37`); its `pool` is not a registered instance of its type (`FD-V38`) |
243
- | `fanout` | `branches: string[]` — the node each branch starts at. Dispatches nothing itself | fewer than two branches, a repeated entry, any `edges` at all, or a branch entry that is not a `task` (`FD-V9`) |
244
- | `map` | `over: '<key>'` and `branch: '<node id>'` — **one** branch, opened once per item at run time. Dispatches nothing itself; the RUN opens N instances of its one branch. Optionally **`needs`**, which is what `over` is then a key on | any `edges` at all, a `branch` that is not a `task`, or an `over` outside `^[A-Za-z0-9_-]{1,64}$` (`FD-V25`); a missing `over` or `branch` (`FD-V23`) |
290
+ | `fanout` | `branches: string[]` — the node each branch starts at. Dispatches nothing itself | fewer than two branches, a repeated entry, any `edges` at all, or a branch entry that is not a `task` or an `ask` (`FD-V9`) |
291
+ | `map` | `over: '<key>'` and `branch: '<node id>'` — **one** branch, opened once per item at run time. Dispatches nothing itself; the RUN opens N instances of its one branch. Optionally **`needs`**, which is what `over` is then a key on | any `edges` at all, a `branch` that is not a `task` or an `ask`, or an `over` outside `^[A-Za-z0-9_-]{1,64}$` (`FD-V25`); a missing `over` or `branch` (`FD-V23`) |
245
292
  | `join` | `of: '<fanout id>'`. Fires when its policy says so | it does not have **exactly one** edge, or that edge carries `when` or `on` (`FD-V7`) |
246
293
  | `end` | terminal. `outcome` is **your label**, never interpreted | — |
247
- | `gate` | `type: '<gate type>'`, optional `settings`, and `edges` of `{ to, route? }` — routes the branch that reaches it and hands on what reached it. `settings.key` is a dotted key path over that value (`series.id`; no index). **Not run by any driver yet** | its `type` is not a registered gate type (`FD-V39`); its `settings` are not a plain object its type's schema accepts, or its `key` is not a key path (`FD-V40`); a route is unwired, wired twice, or not its type's, or a gate edge carries `when`, `on` or `needs` (`FD-V41`) — and `route` on any other node's edge is `FD-V41` too |
294
+ | `ask` | waits for a PERSON — `edges`, and optionally `settings` (opaque), `answerSchema` (what a `done` answer must satisfy) and `needs` (what reaches it). Never dispatched, and has a task id; its answer is its output. A run PARKS at one and takes the person's answer at `resolve()` (section 7) | it has no `edges`, or **no edge of it is eligible for `done`** (`FD-V7`, as a task); `settings` that is not a plain object (`FD-V21`); a key only a task declares — `type`, `pool`, `optional`, `retryPolicy`, `workerGroups`, `transform` — one finding per key (`FD-V47`); an `answerSchema` outside the schema subset (`FD-V48`) |
295
+ | `gate` | `type: '<gate type>'`, optional `settings`, and `edges` of `{ to, route? }` — routes the branch that reaches it and hands on what reached it. `settings.key` is a dotted key path over that value (`series.id`; no index). Built in: `distinct`, `if` and `switch`. Run by the driver where a branch enters it (section 19) | its `type` is not a registered gate type, or its type's `routesFor` throws or returns a malformed route set for this gate, or its `settingsProblem` throws or returns neither a message nor `null` (`FD-V39`); its `settings` are not a plain object its type's schema accepts, its type's `settingsProblem` returns a message, they cannot be copied for the type, or its `key` is not a key path (`FD-V40`); a route is unwired, wired twice, or not its type's, or a gate edge carries `when`, `on` or `needs` (`FD-V41`) — and `route` on any other node's edge is `FD-V41` too |
248
296
 
249
- **A `task` node must have an edge that can be taken when it succeeds.** A node whose only edge is
297
+ **A `task` node — and an `ask` — must have an edge that can be taken when it succeeds.** A node whose only edge is
250
298
  `{ when: { predicate: 'p' }, to: 'x' }` is refused at construction — it is the mistake the format most invites, and
251
299
  `FD-V7` is what catches it.
252
300
 
@@ -263,11 +311,12 @@ reaching the scheduler, which would drop it in silence.
263
311
 
264
312
  | key | | |
265
313
  |---|---|---|
266
- | `retryPolicy` | `{ maxRetries, backoffMs }` — **exactly** the scheduler's shape, an integer >= 0 and a finite number >= 0, no other key (`FD-V30`) | the scheduler retries a failed attempt **inside the same task id**, and the outcome record's `attempts` says how many retries were used. `{}` and a string count are refused because the scheduler would accept them and mean something else |
314
+ | `retryPolicy` | `{ maxRetries, backoffMs }` — **exactly** the scheduler's shape, an integer >= 0 and a finite number >= 0, no other key (`FD-V30`) | the scheduler retries a failed attempt **inside the same task id**, and the outcome record's `attempts` says how many retries were used — since 0.8.0 across a crash or a stop too: a resume hands the attempts already made on, and the budget is not reset (section 18). `{}` and a string count are refused because the scheduler would accept them and mean something else |
267
315
  | `workerGroups` | a non-empty array of distinct group ids, every one in the registry's **`groups`** set (`FD-V31`) | the task acquires these **in addition to** its pool's `groups`. Pass `groups: Object.keys(scheduler.groups)` to the `Driver`, as you pass `nodeTypes` — **omitting it means the registry declares NO groups**, and any node naming one is refused, because a group id the scheduler does not know is silently ignored at run time |
268
316
 
269
317
  **A driver may carry a `defaultRetryPolicy`**, applied to every task node that declares none — held
270
- to `FD-V30` at construction like a node's own — and
318
+ to `FD-V30` at construction like a node's own, and `createRuntime`'s option of the same name at ITS construction
319
+ ([ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md) rule 5) — and
271
320
  **the node wins, whole** — a node with its own policy is dispatched with exactly that object, and the
272
321
  default is consulted only when the node declares none. There is no `defaultWorkerGroups`: a pool's own `groups` already applies to
273
322
  every task it runs, and the scheduler unions the two. A node that declares neither key is dispatched
@@ -275,7 +324,8 @@ with the exact object it was before, so nothing you have already written changes
275
324
 
276
325
  **Flow-level retry — re-dispatching a NODE under a new attempt, possibly routing elsewhere — is
277
326
  still not built** (section 11). This is the scheduler's retry of one task, and it is the whole of
278
- what `attempts` counts.
327
+ what `attempts` counts — and, since 0.8.0, what the run RECORDS: each attempt's start is a fold input,
328
+ `{ kind: 'attempted', taskId, attemptNumber }`, with the scheduler's 1-based number (section 18).
279
329
 
280
330
  ### `needs` — reaching an output the previous node did not produce
281
331
 
@@ -284,7 +334,7 @@ node that says nothing, and it is what the whole of this section above describes
284
334
 
285
335
  **`needs` is how a node asks for something else** —
286
336
  [ADR 0040](doc/decisions/0040-what-a-node-can-reach-is-the-runs-own-outputs-and-a-node-id-names-them-relative-to-a-branch-path.md),
287
- answering [Q11](doc/questions/q11.md). It is an optional object on a `task` or a `map`, mapping the
337
+ answering [Q11](doc/questions/q11.md). It is an optional object on a `task`, a `map` or — since 0.8.0 — an `ask`, mapping the
288
338
  keys of the payload you want to **declared references**. *(Since v0.12 an **edge** may carry one too,
289
339
  and it says what that edge's PREDICATE sees rather than what a worker receives — section 4.)* A node that declares one receives **exactly**
290
340
  that object and nothing else — there is no wrapper and no reserved key.
@@ -292,6 +342,8 @@ that object and nothing else — there is no wrapper and no reserved key.
292
342
  | a reference is | and you get |
293
343
  |---|---|
294
344
  | `"."` | what the node would have received anyway — the previous node's result, and for a `map` instance **its own item** |
345
+ | `"$payload"` | **what the RUN was handed** — the payload `start()` took, from any node and any edge, however many hops in (since 0.8.0, below). Always there: no `?`, no array form |
346
+ | `"$item"` | **what opened your MAP INSTANCE** — its own item, from any node and any edge inside the instance, however many hops in (since 0.8.0, below). Always there inside one; outside every map instance it is `FD-V49` |
295
347
  | `"someNode"` | that node's result, **on your own branch** |
296
348
  | `["someNode"]` | that node's results as an **array**, in ascending branch-path order. Legal only when you are OUTSIDE a region that node is in — which is what makes it more than one |
297
349
  | **a `?` on either of those** — `"someNode?"`, `["someNode?"]` | the same thing, except that **nothing to give leaves the key out** (or gives `[]` for the array form) instead of stopping the run. This is how you say *if it ran* |
@@ -425,6 +477,191 @@ once per item* is a fact about your document rather than about your run. **Measu
425
477
  obstacle** — a size measurement is about 0.03% of what it would warn about at arity 1000.
426
478
  `spec/flow-definition.md` section 3 carries the whole verdict and the measurements.
427
479
 
480
+ ### `"$payload"` — what reached the RUN, from any node
481
+
482
+ **`"$payload"` names the value the run was STARTED with** — `start(flow, payload)`'s `payload` — on any node that may
483
+ declare `needs` (a `task`, a `map`, an `ask`) and on any edge, the entry included (`spec/flow-definition.md` v0.44,
484
+ [ADR 0117](doc/decisions/0117-a-reference-may-name-what-reached-the-run.md), since 0.8.0). Until it, a node reached the
485
+ run's payload only through `"."` on the node that received it — and an ASK breaks that: its result is the person's
486
+ answer (section 7), so the node after an ENTRY ask had no road to what the run started with except a task before the ask
487
+ that passed it through.
488
+
489
+ | | |
490
+ |---|---|
491
+ | what it is | the copy the run's checkpoint HEADER keeps (`header.entryPayload`, section 18) — so a caller changing its payload object after `start()` changes nothing. Each key is its own deep copy, per read: no node receives an object another holds, and a worker writing into it reaches nothing |
492
+ | where it is legal | every node and every edge — at the entry, on a task, it equals `"."`, and both spellings are accepted. **On an edge it is NOT refused** the way `"."` is: a predicate has no `ctx.payload`, so `needs: { run: '$payload' }` is how a decision routes on `needs.run.amount` with no task before it |
493
+ | always there | no `?` and no array form: `["$payload"]` and `"$payload?"` are `FD-V26`, each naming the bare form. The whole `$` prefix is reserved — `$` is outside the node-id charset (`FD-V16`), so the token can never collide with a node — and any other `$` spelling (`"$input"`, a typo) is `FD-V26` naming the two `$` references |
494
+ | `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
495
+ | on a resume | the same value, from the stored header: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` stays 1) |
496
+ | at `start()` | `FD-R4` holds a task's composed `"$payload"` key to its worker's `inputSchema` like any other key (section 9) — composed from the value you passed, before the run exists |
497
+ | in the wiring check | an arc composed through a `"$payload"` key is `unchecked`: the run's payload is a value no schema declares (section 15) |
498
+ | what it costs | one copy of the whole payload **per key per read** — inside a `map`, once per instance (the cliff above, at its largest: name the small part's producer instead, or give the `map` a `needs` that projects what each instance needs). Nothing warns, as above. On an ASK the copy is also written into the run's LOG: the `asked` element keeps what reached the ask (section 7) |
499
+
500
+ **`givenVerdict` and `tryTransform(…, { input })` compose nothing** (section 15): the value you give them IS what the node
501
+ receives, so a node declaring `needs: { run: "$payload" }` is given `{ run: <the payload> }` — composing it is yours, as
502
+ for every `needs` key, `"$item"` included.
503
+
504
+ The app's case — an ENTRY ask whose successor needs the invoice the run started with beside the person's answer, and an
505
+ edge that routes on the invoice's amount:
506
+
507
+ ```ts run
508
+ import { Driver, fold, liveToken, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
509
+
510
+ // A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
511
+ const added: { id: string; payload: { input: unknown } }[] = [];
512
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
513
+ const scheduler = {
514
+ addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
515
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
516
+ };
517
+ const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
518
+
519
+ const flow: FlowDefinition = {
520
+ flowId: 'pay-invoice', version: 1, entry: 'approve',
521
+ nodes: [
522
+ // The ENTRY is an ask: what it shows the person is the run's payload, and its result is the ANSWER alone.
523
+ { id: 'approve', kind: 'ask', needs: { invoice: '$payload' },
524
+ answerSchema: { type: 'object', required: ['approved'], properties: { approved: { type: 'boolean' } } },
525
+ edges: [{ on: ['failed'], to: 'declined' }, { to: 'pay' }] },
526
+ // `pay` reaches BOTH: the invoice the run started with, and the person's answer.
527
+ { id: 'pay', kind: 'task', type: 'Pay', needs: { invoice: '$payload', answer: 'approve' },
528
+ edges: [
529
+ // An EDGE may name it too, so its predicate routes on the run's payload with no task in between.
530
+ { when: { path: 'needs.run.amount', op: 'gt', value: 100 }, to: 'audit', needs: { run: '$payload' } },
531
+ { to: 'done' },
532
+ ] },
533
+ { id: 'audit', kind: 'task', type: 'Pay', edges: [{ to: 'done' }] },
534
+ { id: 'declined', kind: 'end', outcome: 'declined' },
535
+ { id: 'done', kind: 'end', outcome: 'completed' },
536
+ ],
537
+ };
538
+ const types = { workers: { w: { url: 'worker.js' } }, nodeTypes: { Pay: { worker: 'w' } } };
539
+ const registry = { predicates: [], ...types };
540
+ if (validateDefinition(flow, registry).length) throw new Error('"$payload" is legal on an entry ask, on a task and on an edge');
541
+
542
+ const driver = new Driver(flow, { runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken, ...types });
543
+ const invoice = { id: 'INV-1', amount: 120 };
544
+ driver.start(invoice);
545
+ invoice.amount = 1; // yours to change: the run holds its own copy
546
+
547
+ driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' });
548
+ console.log(inputOf('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
549
+ if (JSON.stringify(inputOf('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
550
+ throw new Error(`pay received ${JSON.stringify(inputOf('r1:pay:-:0'))}`);
551
+ }
552
+
553
+ // `pay` settles; its edge reads `needs.run.amount` — 120, the run's — and routes to the audit.
554
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail: { id: 'r1:pay:-:0', state: 'done', result: { paid: true } } });
555
+ console.log(added.map((t) => t.id)); // [ 'r1:pay:-:0', 'r1:audit:-:0' ]
556
+ if (added.map((t) => t.id).join() !== 'r1:pay:-:0,r1:audit:-:0') throw new Error('120 > 100 routes to the audit');
557
+ if (fold(driver.foldInputs()).outcome !== 'running') throw new Error('the audit is in flight');
558
+
559
+ // The refusals name their fix: one payload, always there, and the `$` prefix is reserved.
560
+ const refuse = (ref: unknown) => validateDefinition({ ...flow, nodes: flow.nodes.map((n) =>
561
+ (n.id === 'pay' ? { ...n, needs: { invoice: ref } } : n)) } as FlowDefinition, registry).map((f) => `${f.rule}: ${f.detail}`);
562
+ for (const [ref, fix] of [[['$payload'], 'write "$payload"'], ['$payload?', 'without "?"'], ['$input', '"$payload" (the run\'s payload)']] as const) {
563
+ const [finding] = refuse(ref);
564
+ if (!finding?.startsWith('FD-V26') || !finding.includes(fix)) throw new Error(`${JSON.stringify(ref)}: ${finding}`);
565
+ }
566
+ ```
567
+
568
+ **BREAKING in 0.8.0** (P38 (h)): `referenceTarget`'s return type gains two variants, `{ form: 'payload' }` and
569
+ `{ form: 'item' }`, each with `node: null` — so code switching exhaustively on `form` stops compiling, and code reading
570
+ `node === null` as *the `"."` form* reads `"$payload"` as `"."` in silence: switch on `form`. The `Reference` TYPE does
571
+ not move (every token is a string), and a flow that names neither token is unchanged — every tree before 0.8.0's W190
572
+ refuses both.
573
+
574
+ ### `"$item"` — what opened the MAP INSTANCE, from any node in it
575
+
576
+ **`"$item"` names the item the reader's map instance was opened with** — the element of `over`'s array — on any node that
577
+ may declare `needs` (a `task`, a `map`, an `ask`) and on any edge INSIDE a map instance, however many hops into it
578
+ (`spec/flow-definition.md` v0.45, [ADR 0118](doc/decisions/0118-a-map-instance-keeps-its-item-while-a-node-of-it-may-still-be-entered.md),
579
+ since 0.8.0). Until it, an instance's item reached only its branch ENTRY, as `"."` — and an ASK as a map's branch breaks
580
+ that: its result is the person's answer, so the node after it had no road to the item except a task before the ask that
581
+ passed it through.
582
+
583
+ | | |
584
+ |---|---|
585
+ | which instance | the INNERMOST map region that CONTAINS the reader. A node in a `fanout` inside a map instance names the MAP's item; a `map` node's own `"$item"` is its ENCLOSING instance's (a map never names the region it opens); an OUTER map's item from inside an inner one is not named — a node of the outer instance that declares it passes it down |
586
+ | where it is legal | every node and every edge inside a map instance — on the branch entry it equals `"."`, and both are accepted; on an edge it is NOT refused the way `"."` is. **Outside every map instance it is `FD-V49`** — before every map, after a map's join, or on a top-level `map` node — naming both fixes: *name the item where a map's branch receives it (`"."`), or move this node inside the map* |
587
+ | always there | no `?` and no array form: `["$item"]` and `"$item?"` are `FD-V26`, each naming the bare form |
588
+ | what it is | its OWN deep copy of the item, per key per read — two `"$item"` keys on one node are two objects, and a worker or a predicate writing into one reaches nothing else |
589
+ | what an instance KEEPS | its item, by reference to the element the map received — and only where some node of that map's region reads `"$item"`: a map nobody reads it from keeps nothing. **Released when nothing of that instance may still be entered** — its branch has ended (arrived at the map's join, exited the region, or been lost) AND no branch of a region nested inside it is still live — told by those branches' terminal region events, never by a join: an ask parked in the instance keeps it until the person answers, and a nested fan-out whose join a policy fired early keeps it until its last branch ends. Released whole when the run halts, is cancelled or is stopped (`spec/run-state.md` `RN-R13`) |
590
+ | on a resume | rebuilt by the replay, which re-enters the map: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` stays 1) |
591
+ | at `start()` | `FD-R4` composes it on an ENTRY map's branch task from the element the walk reached — which IS that instance's item — and holds it to the worker's `inputSchema` like any key (section 9). *Until 0.8.0's W191 ANY `needs` on such a task — `"."` included — made `start()` throw an `UnresolvedBranchPath` instead of `BoundaryRefused`* |
592
+ | in the wiring check | an arc composed through a `"$item"` key reads the enclosing map's ITEM schema, one sample per element; `unchecked` for an ENTRY map, whose items come from the run's payload, which no schema declares (section 15) |
593
+ | what it costs | one copy of ONE element per key per read — the cheap end of section 3's cliff — and the retention above, at most the `over` array the log already holds |
594
+
595
+ The app's case — a map whose branch is an ASK, and the node after it reaching its instance's invoice beside the person's
596
+ answer, and an edge that routes on that invoice's amount:
597
+
598
+ ```ts run
599
+ import { Driver, fold, liveToken, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
600
+
601
+ // A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
602
+ const added: { id: string; payload: { input: unknown } }[] = [];
603
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
604
+ const scheduler = {
605
+ addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
606
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
607
+ };
608
+ const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
609
+
610
+ const flow: FlowDefinition = {
611
+ flowId: 'review-each', version: 1, entry: 'each',
612
+ nodes: [
613
+ // One instance per invoice, and each instance's branch is an ASK: its result is the answer alone.
614
+ { id: 'each', kind: 'map', over: 'invoices', branch: 'review' },
615
+ { id: 'review', kind: 'ask',
616
+ answerSchema: { type: 'object', required: ['approved'], properties: { approved: { type: 'boolean' } } },
617
+ edges: [{ on: ['failed'], to: 'skipped' }, { to: 'pay' }] },
618
+ // `pay` reaches BOTH: its own instance's invoice, and the person's answer.
619
+ { id: 'pay', kind: 'task', type: 'Pay', needs: { invoice: '$item', answer: 'review' },
620
+ edges: [
621
+ // An EDGE may name it too, so a predicate routes on the instance's invoice.
622
+ { when: { path: 'needs.it.amount', op: 'gt', value: 100 }, to: 'audit', needs: { it: '$item' } },
623
+ { to: 'collect' },
624
+ ] },
625
+ { id: 'audit', kind: 'task', type: 'Pay', edges: [{ to: 'collect' }] },
626
+ { id: 'skipped', kind: 'end', outcome: 'skipped' },
627
+ { id: 'collect', kind: 'join', of: 'each', edges: [{ to: 'done' }] },
628
+ { id: 'done', kind: 'end', outcome: 'completed' },
629
+ ],
630
+ };
631
+ const types = { workers: { w: { url: 'worker.js' } }, nodeTypes: { Pay: { worker: 'w' } } };
632
+ const registry = { predicates: [], ...types };
633
+ if (validateDefinition(flow, registry).length) throw new Error('"$item" is legal on a node and on an edge inside a map instance');
634
+
635
+ const driver = new Driver(flow, { runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken, ...types });
636
+ const payload = { invoices: [{ id: 'INV-1', amount: 120 }, { id: 'INV-2', amount: 40 }] };
637
+ driver.start(payload);
638
+ payload.invoices[0]!.amount = 1; // yours to change: the instance keeps its own item
639
+
640
+ // Two asks parked, one per invoice. Answered out of order, each `pay` gets ITS instance's invoice.
641
+ driver.resolve('r1:review:1:0', 'done', { result: { approved: true }, actor: 'bo' });
642
+ driver.resolve('r1:review:0:0', 'done', { result: { approved: true }, actor: 'ana' });
643
+ console.log(inputOf('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
644
+ if (JSON.stringify(inputOf('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
645
+ throw new Error(`pay@0 received ${JSON.stringify(inputOf('r1:pay:0:0'))}`);
646
+ }
647
+ if (JSON.stringify(inputOf('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
648
+ throw new Error(`pay@1 received ${JSON.stringify(inputOf('r1:pay:1:0'))}`);
649
+ }
650
+
651
+ // Each `pay` settles; its edge reads `needs.it.amount` — 120 audits, 40 does not.
652
+ for (const id of ['r1:pay:0:0', 'r1:pay:1:0']) {
653
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail: { id, state: 'done', result: { paid: true } } });
654
+ }
655
+ console.log(added.map((t) => t.id)); // [ 'r1:pay:1:0', 'r1:pay:0:0', 'r1:audit:0:0' ]
656
+ if (added.map((t) => t.id).join() !== 'r1:pay:1:0,r1:pay:0:0,r1:audit:0:0') throw new Error('120 > 100 audits instance 0 alone');
657
+ if (fold(driver.foldInputs()).outcome !== 'running') throw new Error('the audit is in flight');
658
+
659
+ // Outside every map instance there is no item: a top-level MAP's own "$item" would be an ENCLOSING one — FD-V49.
660
+ const outside = { ...flow, nodes: flow.nodes.map((n) => (n.id === 'each' ? { ...n, needs: { invoices: '$item' } } : n)) } as FlowDefinition;
661
+ const [finding] = validateDefinition(outside, registry);
662
+ if (finding?.rule !== 'FD-V49' || !finding.detail.includes('move this node inside the map')) throw new Error(`expected FD-V49, got ${finding?.rule}`);
663
+ ```
664
+
428
665
  ### Validation happens at construction, not at routing time
429
666
 
430
667
  `new Driver(...)` validates the whole definition before anything is queued. A typo cannot become a
@@ -447,7 +684,7 @@ if (!rules.includes('FD-V2') || !rules.includes('FD-V32')) {
447
684
  }
448
685
  ```
449
686
 
450
- There are **42 refusals** over the id range `FD-V1` … `FD-V46` *(41 over `FD-V1` … `FD-V45` until W109 shipped the first BUILT-IN node type and added `FD-V46`, which refuses a node of a type that only transforms and declares no `transform`; 37 over `FD-V1` … `FD-V41` until W108 made a predicate DATA and added the four a `when` needs; 34 over `FD-V1` … `FD-V38` until W92 added the gate's three; 35 over `FD-V1` … `FD-V37` until W65 retired `workerType` and with it `FD-V5` and `FD-V34`, adding `FD-V38`; 32 over `FD-V1` … `FD-V34` until W61 added the three transform rules; 29 over `FD-V1` … `FD-V31` until W59 added the three node-type rules — and this sentence stood at 29 twelve lines below the table W59 rewrote, until its conformity reader read the two together)*, each with a failing example in
687
+ There are **45 refusals** over the id range `FD-V1` … `FD-V49` *(44 over `FD-V1` … `FD-V48` until W191 added `FD-V49` — `"$item"` where no map instance contains the reader; 42 over `FD-V1` … `FD-V46` until W179 added the ASK's two, `FD-V47` — a key only a task declares, on an ask — and `FD-V48` — an `answerSchema` outside the schema subset; 41 over `FD-V1` … `FD-V45` until W109 shipped the first BUILT-IN node type and added `FD-V46`, which refuses a node of a type that only transforms and declares no `transform`; 37 over `FD-V1` … `FD-V41` until W108 made a predicate DATA and added the four a `when` needs; 34 over `FD-V1` … `FD-V38` until W92 added the gate's three; 35 over `FD-V1` … `FD-V37` until W65 retired `workerType` and with it `FD-V5` and `FD-V34`, adding `FD-V38`; 32 over `FD-V1` … `FD-V34` until W61 added the three transform rules; 29 over `FD-V1` … `FD-V31` until W59 added the three node-type rules — and this sentence stood at 29 twelve lines below the table W59 rewrote, until its conformity reader read the two together)*, each with a failing example in
451
688
  `spec/examples/invalid/`. Four ids in that range are **deliberately absent** and have no rule behind
452
689
  them: `FD-V14`, which never existed; **`FD-V15`, retired when nested fan-out became
453
690
  expressible**; and **`FD-V5` and `FD-V34`, retired with `workerType`** at v0.19 (section 13). The ids are never renumbered, so it is safe to assert on them. The full
@@ -594,8 +831,10 @@ flagged low confidence* was not expressible until v0.12 —
594
831
  answering [Q8](doc/questions/q8.md).
595
832
 
596
833
  **An edge may carry `needs`, and its predicate sees the result under `ctx.needs`.** Same grammar as
597
- a node's (section 3) — a node id, or a one-element array holding one — **minus `"."`**, which an edge
598
- has no use for: *what a predicate would have received anyway* is `ctx.outcome`.
834
+ a node's (section 3) — a node id, or a one-element array holding one, and since 0.8.0 `"$payload"` and `"$item"` —
835
+ **minus `"."`**, which an edge has no use for: *what a predicate would have received anyway* is `ctx.outcome`. `"$payload"`
836
+ and `"$item"` are not refused there: a predicate has no other road to the run's payload or to its map instance's item
837
+ (section 3's two subsections route on them).
599
838
 
600
839
  ```ts run
601
840
  import { validateDefinition, type FlowDefinition, type Predicate } from 'fyflow-orchestration';
@@ -656,7 +895,10 @@ changes what its **worker** receives, so you could not give a predicate reach wi
656
895
  worker; and *what the node received* is not something the driver can reconstruct when the decision is
657
896
  made — the **entry** node's payload is never written to the log at all, and reconstructing any other
658
897
  node's would mean re-deriving the whole payload contract a second time. **A reference resolves from
659
- what the run produced**, which is available either way.
898
+ what the run produced**, which is available either way — **and from what the run was HANDED**, since 0.8.0: the
899
+ checkpoint header keeps the run's payload for the life of the run, and `"$payload"` names it — **and, inside a map
900
+ instance, from what the instance KEEPS**: its item, while a node of it may still be entered, which `"$item"` names
901
+ (section 3).
660
902
 
661
903
  **`ctx.needs` is `{}` when the edge declares nothing**, never `undefined`; it is **frozen**, so a
662
904
  predicate that writes to it throws rather than editing what the next predicate reads; and a reference
@@ -903,26 +1145,22 @@ It is also `README.md`'s second snippet, held byte-equal to this one by `check-d
903
1145
  import { createRuntime } from 'fyflow-orchestration/scheduler';
904
1146
  import { type FlowDefinition, type Predicate, type RegistryDocument, type RunState } from 'fyflow-orchestration';
905
1147
 
906
- // THE REGISTRY — one worker, one type, and the NAME of a predicate the flow may use. A predicate is
907
- // code (ADR 0002), supplied beside the document under that name; a flow using a name nobody supplied
908
- // is refused at construction, not at the decision. The worker tags its input with the node's label.
1148
+ // THE REGISTRY — one worker, one type, and the NAME of a predicate the flow may use. The predicate
1149
+ // itself is code, passed to `createRuntime` under that name. The worker tags its input with a label.
909
1150
  const registry: RegistryDocument = {
910
1151
  workers: { tag: { url: '../../examples/getting-started/tag.ts' } }, // in your program: './tag.ts'
911
1152
  nodeTypes: { Tag: { worker: 'tag', inline: true } },
912
1153
  predicates: ['isLowConfidence'],
913
1154
  };
914
- // A predicate reads the EFFECTIVE outcome, and `result` is `null` for an optional task that
915
- // failed (`OR-R9`) — so it is guarded, as section 4's is. Unreachable here, because `ingest` is
916
- // not optional; guarded anyway, because this is the form a consumer copies.
1155
+ // `result` is `null` when an optional task failed, so a predicate guards it.
917
1156
  const isLowConfidence: Predicate = (ctx) => {
918
1157
  const r = ctx.outcome.result as { confidence?: number } | null;
919
1158
  return typeof r?.confidence === 'number' && r.confidence < 0.5;
920
1159
  };
921
1160
 
922
- // THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest`'s first edge is taken when its predicate says so,
923
- // otherwise the last, unconditional one. `split` opens two branches that run concurrently; `collect`
924
- // waits for both and hands `publish` every branch's result keyed by branch path (JP-R12). `review` is
925
- // the road the run takes when confidence is low — it never reaches the join, and the run completes.
1161
+ // THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest` goes to `review` when the predicate holds,
1162
+ // otherwise to `split`. `split` runs two branches concurrently; `collect` waits for both and hands
1163
+ // `publish` every branch's result, keyed by branch path.
926
1164
  const flow: FlowDefinition = {
927
1165
  flowId: 'decide', version: 1, entry: 'ingest',
928
1166
  nodes: [
@@ -938,30 +1176,24 @@ const flow: FlowDefinition = {
938
1176
  ],
939
1177
  };
940
1178
 
941
- // ONE runtime, TWO runs. Pools belong to the runtime and are shared across runs; each run is its own
942
- // `Driver` with its own fold, and the join policy defaults to `live-token` (ADR 0003).
1179
+ // ONE runtime, TWO runs. Pools belong to the runtime and are shared; each run has its own state.
943
1180
  const runtime = createRuntime(registry, { predicates: { isLowConfidence } }, { base: import.meta.url });
944
- // Each run has its OWN `done`, so two runs are two promises — `start` is called twice and the whole
945
- // of the wait is `await`. Nothing here folds anything: the runtime keeps the fold (ADR 0058).
946
1181
  const run = (payload: unknown) => runtime.start(flow, payload).done;
947
1182
  const high = await run({ confidence: 0.9 });
948
1183
  const low = await run({ confidence: 0.2 });
949
1184
  await runtime.shutdown();
950
1185
 
951
- // WHICH nodes ran, read off each run's own state — the decision took a different edge each time.
1186
+ // Which nodes ran: the decision took a different edge each time.
952
1187
  const ran = (s: RunState) => Object.values(s.nodes).map((n) => n.nodeId).sort();
953
1188
  const resultOf = (s: RunState, id: string) => Object.values(s.nodes).find((n) => n.nodeId === id)?.result;
954
1189
  console.log(ran(high)); // [ 'classify', 'enrich', 'ingest', 'publish' ]
955
1190
  console.log(ran(low)); // [ 'ingest', 'review' ]
956
1191
  console.log(resultOf(high, 'publish')); // { '0': { confidence: 0.9, ingest: true, enrich: true }, '1': { …, classify: true }, publish: true }
957
1192
 
1193
+ // Verified by the docs gate:
958
1194
  if (high.outcome !== 'completed' || low.outcome !== 'completed') throw new Error(`outcomes ${high.outcome} / ${low.outcome}`);
959
1195
  if (ran(high).join() !== 'classify,enrich,ingest,publish') throw new Error(`high ran ${ran(high).join()}`);
960
1196
  if (ran(low).join() !== 'ingest,review') throw new Error(`low ran ${ran(low).join()}`);
961
- // `publish` received BOTH branches' results, keyed by branch path (JP-R12), and each branch's value
962
- // carries the whole road it came by. What this string pins is the branch-to-KEY MAPPING: the ASCENDING
963
- // order that rule also fixes is not observable at depth 1, because "0" and "1" are integer-like keys
964
- // and the language orders those ascending whatever the driver did. A nested region would show it.
965
1197
  const want = '{"0":{"confidence":0.9,"ingest":true,"enrich":true},"1":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
966
1198
  if (JSON.stringify(resultOf(high, 'publish')) !== want) throw new Error(`publish received ${JSON.stringify(resultOf(high, 'publish'))}`);
967
1199
  ```
@@ -1006,6 +1238,193 @@ the table above and is wrong. `resolve(taskId, 'failed', …)` routes exactly as
1006
1238
  failure would, and `RS-R3` notes it is **the only road by which a non-`optional` node reaches an
1007
1239
  `on: ['failed']` edge**. That is the half of the human-in-the-loop story section 7 completes.
1008
1240
 
1241
+ ### A task that ends `cancelled` ends its RUN
1242
+
1243
+ The scheduler can end a task a third way: **`scheduler.cancel(id)`** stops it — waiting or running,
1244
+ `optional` or not, whatever its attempt produced — and it ends in state `cancelled` with one event,
1245
+ `task.cancelled`. The driver hears it, records it (`OR-R3`), and **halts the run**: nothing is dispatched
1246
+ or routed for it again, and the run folds to **`cancelled`** (`RN-R28`,
1247
+ [ADR 0094](doc/decisions/0094-a-cancelled-task-is-a-terminal-state-that-routes-no-edge-and-ends-its-run-cancelled.md)).
1248
+
1249
+ | | what happens |
1250
+ |---|---|
1251
+ | the cancelled task | one record, `terminalState: 'cancelled'`, `result: null`. `task.cancelling` — the request, while a running task stops — writes nothing (`OR-R4`) |
1252
+ | an edge | **none can opt in**: `on: ['cancelled']` is refused at construction (`FD-V18`). A cancel is the owner's act, not an outcome a flow branches on |
1253
+ | the rest of the run | a task still in flight is **routed nowhere** when it settles — a bare `Driver` still records it; on a runtime, once `done` has settled the run is RELEASED and later settlements reach no driver (section 12, [ADR 0091](doc/decisions/0091-a-runtime-releases-a-run-when-its-done-settles-and-keeps-only-its-run-id.md)); a parked task stays parked and `resolve()` returns `false`; a join it left waiting never fires, and the run is NOT `stuck`. **Also on your own stack**: a cancel raised SYNCHRONOUSLY from your own `onFoldInput` — while a fan-out is dispatching its branches, a resolution is routing, or a resume is handing its tasks back — holds every further dispatch, as a halt does (`RN-R29`, [ADR 0103](doc/decisions/0103-every-halt-shares-one-dispatch-hold.md)); the one task whose own `dispatched` element you were handed is still handed to the scheduler |
1254
+ | what is reported | nothing: a cancel is not a refusal, so `onRunFailure` is not called and `hasFailed()` stays `false` — read `state.outcome` |
1255
+ | `done` | settles on the FIRST cancel, with `outcome: 'cancelled'` — the run's other tasks may still be running; cancel those by id, or cancel the RUN with `driver.cancel` instead (below), whose `done` waits for every task to stop |
1256
+ | a resume | refused, `reason: 'cancelled'` (section 18) |
1257
+
1258
+ ```ts run
1259
+ import { Driver, fold, liveToken, resumeRefusal, type FlowDefinition } from 'fyflow-orchestration';
1260
+
1261
+ const flow: FlowDefinition = {
1262
+ flowId: 'cancel-one', version: 1, entry: 'split',
1263
+ nodes: [
1264
+ { id: 'split', kind: 'fanout', branches: ['a', 'b'] },
1265
+ { id: 'a', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1266
+ { id: 'b', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1267
+ { id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'publish' }] },
1268
+ { id: 'publish', kind: 'task', type: 'T', edges: [{ to: 'done' }] },
1269
+ { id: 'done', kind: 'end', outcome: 'completed' },
1270
+ ],
1271
+ };
1272
+
1273
+ // The fake scheduler of section 12's no-scheduler snippet: listeners keyed by event type.
1274
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1275
+ const added: string[] = [];
1276
+ const failures: string[] = [];
1277
+ const scheduler = {
1278
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
1279
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) {
1280
+ listeners.set(type, [...(listeners.get(type) ?? []), fn]);
1281
+ },
1282
+ };
1283
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
1284
+
1285
+ const driver = new Driver(flow, {
1286
+ runId: 'run-1', scheduler, createTask: (c) => c, predicates: new Map(),
1287
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } },
1288
+ joinPolicy: liveToken, onRunFailure: (f) => { failures.push(f.kind); },
1289
+ });
1290
+ driver.start({});
1291
+ console.log(added); // [ 'run-1:a:0:0', 'run-1:b:1:0' ]
1292
+
1293
+ // Somebody cancels branch `a` — the scheduler fires `task.cancelled`, and nothing else, for it.
1294
+ fire('task.cancelled', { id: 'run-1:a:0:0', state: 'cancelled' });
1295
+ // Branch `b` was already running; it finishes. It is recorded, and routed nowhere.
1296
+ fire('task.completed', { id: 'run-1:b:1:0', state: 'done', result: { b: 1 } });
1297
+
1298
+ const state = fold(driver.foldInputs());
1299
+ console.log(state.outcome, added.length, failures); // cancelled 2 []
1300
+ if (state.outcome !== 'cancelled') throw new Error(`expected cancelled, got ${state.outcome}`);
1301
+ if (added.includes('run-1:publish:-:0')) throw new Error('a cancelled run must not route on to the join`s successor');
1302
+ if (failures.length !== 0) throw new Error('a cancel is not a run failure');
1303
+ if (state.nodes['run-1:b:1:0']?.state !== 'done') throw new Error('the in-flight task is still recorded');
1304
+
1305
+ // And the run cannot be resumed.
1306
+ console.log(resumeRefusal(flow, driver.checkpoint())?.reason); // 'cancelled'
1307
+ if (resumeRefusal(flow, driver.checkpoint())?.reason !== 'cancelled') throw new Error('a cancelled run is not resumable');
1308
+ ```
1309
+
1310
+ **That is a cancel the run NOTICES** — one you made through the scheduler, by task id, and the run ends the
1311
+ moment the first is heard. To cancel the RUN, ask the driver.
1312
+
1313
+ **`'cancelled'` is a NEW value of four published unions** — `TerminalState`, `NodeState`, `RunOutcome` and
1314
+ `ResumeRefusalReason`. **A `switch` that is exhaustive over any of them no longer compiles, which is the
1315
+ loud half; code that compares strings sees a value it never named and does not notice, which is the
1316
+ silent half.** A `Record<RunOutcome, …>` needs a seventh key — and since 0.8.0 an eighth, `cancelling`, below, and a ninth, `interrupted` (section 18).
1317
+
1318
+ ### Cancelling a RUN — `driver.cancel`
1319
+
1320
+ **`driver.cancel({ actor, reason?, graceMs? })` cancels the whole run and resolves once every task it
1321
+ cancelled has stopped** (`RN-R31`,
1322
+ [ADR 0104](doc/decisions/0104-a-run-its-owner-cancels-is-cancelling-until-its-last-task-has-stopped.md)). In
1323
+ order: the run is HELD — nothing is dispatched or routed for it again (`RN-R29`); ONE fold input,
1324
+ `{ kind: 'cancelRequested', actor, reason }`, is appended; then the scheduler's `cancel(id, { reason, graceMs })`
1325
+ is called for each task IN FLIGHT. Each task still ends with the scheduler's own `task.cancelled` (or whatever it
1326
+ reached first), appended and routed nowhere. **The run reads `cancelling` until its LAST task has stopped, and
1327
+ `cancelled` after** (`RN-R30`) — so a runtime's `done` settles after the last `task.cancelled`, not at the first.
1328
+
1329
+ | | what happens |
1330
+ |---|---|
1331
+ | a task RUNNING, WAITING behind a group, in a retry BACKOFF, or not yet picked up by a thread | each is cancelled; a waiting or backing-off one settles INSIDE the call, a running one when its worker stops |
1332
+ | a task PARKED at `user_action` | nothing is sent — it is not in flight, and the scheduler cannot end it. It stays `suspended`, the run does not wait on it, and `resolve()` is refused (P34 (i)) |
1333
+ | an ASK waiting for a person (section 7) | the same as a parked task: nothing is sent — it was never dispatched, so it is never in flight. It stays `suspended` and its answer is refused; a run waiting ONLY on asks and parked tasks reads `cancelled` at once, and a runtime's `done` settles at once ([W181](doc/work-items/w181-an-ask-under-run-control.md), `RN-R38`) |
1334
+ | a task HELD by a pause (section 8) | handed on to the scheduler, then cancelled at once — so it too ends with the scheduler's own `task.cancelled` |
1335
+ | a task a WORKER spawned (`context.spawnTask()`) | **not cancelled** — the driver knows only what it dispatched. Track those ids from `task.spawn_request` and cancel each yourself (the scheduler's own recipe) |
1336
+ | a SECOND call | appends nothing, and forwards its `graceMs` to each task still stopping — a shorter one shortens the wait, as pressing Cancel twice does on the scheduler |
1337
+ | `graceMs: 0` | **a request, not a guarantee**: the scheduler arms a 0 ms timer, and a worker that honours its signal promptly can still beat it ([S12](doc/scheduler-upstream.md)). A test needing a FORCED stop uses a worker that ignores its signal |
1338
+ | refused | **`RunControlRefused`, by `reason`, with nothing recorded or sent**: `unsupported` (the scheduler has no `cancel` — section 12), `not-started`, `halted`, `cancelled` (a task the scheduler cancelled on its own already ended the run), `ended` (nothing in flight, nothing parked, the driver at rest — a cancel from your own callback mid-step is accepted, and stops what the step would have dispatched) and `interrupted` (the run was STOPPED — cancel it where it resumes, section 18) — and a bad `graceMs` throws `TypeError` or `RangeError`, as the scheduler would |
1339
+ | a resume | refused, `reason: 'cancelled'`, at the `cancelRequested` element (section 18) |
1340
+ | your `onFoldInput` THROWS on the `cancelRequested` element | every task in flight is still sent its cancel and every task a pause held handed on and cancelled — in a `finally` (`RN-R31`) — and THEN the run is HALTED and reported once, and the throw reaches `cancel()`'s caller (section 9, [W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md)). `halted` outranks `cancelling`, so a runtime's `done` settles `halted` at once; the tasks still stop, routed nowhere. *Until 0.8.0's W178 it sent none, and a paused run read `cancelling` for ever; until W184 the run read `cancelling`, unhalted* |
1341
+
1342
+ ```ts run
1343
+ import { Driver, RunControlRefused, fold, liveToken, resumeRefusal, type FlowDefinition } from 'fyflow-orchestration';
1344
+
1345
+ const flow: FlowDefinition = {
1346
+ flowId: 'cancel-run', version: 1, entry: 'split',
1347
+ nodes: [
1348
+ { id: 'split', kind: 'fanout', branches: ['a', 'b'] },
1349
+ { id: 'a', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1350
+ { id: 'b', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1351
+ { id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'publish' }] },
1352
+ { id: 'publish', kind: 'task', type: 'T', edges: [{ to: 'done' }] },
1353
+ { id: 'done', kind: 'end', outcome: 'completed' },
1354
+ ],
1355
+ };
1356
+
1357
+ // A fake scheduler WITH the scheduler's own `cancel(taskId, options)`: a running task stops when its worker does.
1358
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1359
+ const added: string[] = [];
1360
+ const sent: { id: string; graceMs?: number }[] = [];
1361
+ const stopping = new Map<string, { promise: Promise<boolean>; resolve: (stopped: boolean) => void }>();
1362
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
1363
+ const scheduler = {
1364
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
1365
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) {
1366
+ listeners.set(type, [...(listeners.get(type) ?? []), fn]);
1367
+ },
1368
+ cancel(id: string, options?: { reason?: unknown; graceMs?: number }) {
1369
+ sent.push({ id, graceMs: options?.graceMs });
1370
+ // A repeat call while the task is still stopping returns the SAME promise, as the scheduler's does.
1371
+ let s = stopping.get(id);
1372
+ if (!s) {
1373
+ let resolve!: (stopped: boolean) => void;
1374
+ s = { promise: new Promise<boolean>((r) => { resolve = r; }), resolve };
1375
+ stopping.set(id, s);
1376
+ }
1377
+ return s.promise;
1378
+ },
1379
+ };
1380
+ // The worker of `id` has stopped: the scheduler fires `task.cancelled`, and its `cancel()` resolves.
1381
+ const stops = (id: string) => { fire('task.cancelled', { id, state: 'cancelled' }); stopping.get(id)?.resolve(true); };
1382
+
1383
+ const options = {
1384
+ runId: 'run-1', createTask: (c: unknown) => c, predicates: new Map(),
1385
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } }, joinPolicy: liveToken,
1386
+ };
1387
+ const driver = new Driver(flow, { ...options, scheduler });
1388
+ driver.start({});
1389
+
1390
+ const cancelled = driver.cancel({ actor: 'ana', reason: 'no longer needed', graceMs: 2000 });
1391
+ console.log(sent.map((s) => s.id), fold(driver.foldInputs()).outcome); // [ 'run-1:a:0:0', 'run-1:b:1:0' ] cancelling
1392
+ if (fold(driver.foldInputs()).outcome !== 'cancelling') throw new Error('a run whose tasks are stopping is `cancelling`');
1393
+
1394
+ stops('run-1:a:0:0');
1395
+ console.log(fold(driver.foldInputs()).outcome); // cancelling — b has not stopped yet
1396
+ if (fold(driver.foldInputs()).outcome !== 'cancelling') throw new Error('`cancelled` waits for the LAST task');
1397
+
1398
+ // Press Cancel again: nothing is recorded, and the task still stopping is sent the shorter graceMs.
1399
+ void driver.cancel({ actor: 'ana', graceMs: 0 });
1400
+ console.log(sent.at(-1)); // { id: 'run-1:b:1:0', graceMs: 0 }
1401
+ if (sent.length !== 3 || sent[2]!.id !== 'run-1:b:1:0' || sent[2]!.graceMs !== 0) throw new Error('the second call escalates what is still stopping');
1402
+
1403
+ stops('run-1:b:1:0');
1404
+ await cancelled;
1405
+ const state = fold(driver.foldInputs());
1406
+ console.log(state.outcome, state.cancelRequest); // cancelled { actor: 'ana', reason: 'no longer needed' }
1407
+ if (state.outcome !== 'cancelled' || state.cancelRequest?.actor !== 'ana') throw new Error('cancelled, by ana');
1408
+ if (driver.foldInputs().filter((e) => e.kind === 'cancelRequested').length !== 1) throw new Error('ONE element for two calls');
1409
+ if (added.includes('run-1:publish:-:0')) throw new Error('a cancelled run routes nowhere');
1410
+ if (resumeRefusal(flow, driver.checkpoint())?.reason !== 'cancelled') throw new Error('a cancelled run is not resumable');
1411
+
1412
+ // A scheduler with no `cancel` drives a run, and refuses to cancel it — by name, before recording anything.
1413
+ const plain = new Driver(flow, { ...options, runId: 'run-2', scheduler: { addTask() {}, addEventListener() {} } });
1414
+ plain.start({});
1415
+ let refused: unknown = null;
1416
+ try { void plain.cancel({ actor: 'ana' }); } catch (e) { refused = e; }
1417
+ console.log(refused instanceof RunControlRefused && refused.reason); // 'unsupported'
1418
+ if (!(refused instanceof RunControlRefused) || refused.reason !== 'unsupported') throw new Error('no cancel, no run cancel');
1419
+ if (plain.foldInputs().some((e) => e.kind === 'cancelRequested')) throw new Error('and nothing was recorded');
1420
+ ```
1421
+
1422
+ **BREAKING in 0.8.0, in four places** (P34 decision 10): `RunOutcome` gains `'cancelling'` — the first NON-terminal
1423
+ outcome since `awaiting_action`, so `isTerminal` is false for it and an exhaustive `switch` no longer compiles;
1424
+ `FoldInput` gains the kind `'cancelRequested'`; `RunState` gains the cell `cancelRequest` (`null` until the owner
1425
+ cancels), which a `RunState` literal must now carry; and `SchedulerLike` gains an OPTIONAL `cancel`, which breaks only
1426
+ a double that declares a `cancel` of another shape (section 12).
1427
+
1009
1428
  ---
1010
1429
 
1011
1430
  ## 7. The human in the loop
@@ -1013,6 +1432,167 @@ failure would, and `RS-R3` notes it is **the only road by which a non-`optional`
1013
1432
  A non-optional task that runs out of retries ends in `user_action`. The branch **suspends**: the join
1014
1433
  keeps waiting, and the run is not stuck.
1015
1434
 
1435
+ **A node that waits for a person BY DESIGN is a different thing, and it is the `ask` kind** (section 3, since 0.8.0's
1436
+ `spec/flow-definition.md` v0.42): no task fails to get there, and a question is not an error. **A run PARKS at it**
1437
+ ([W180](doc/work-items/w180-asking-and-answering-in-a-run.md), [ADR 0109](doc/decisions/0109-an-ask-parks-with-nothing-dispatched-and-its-answer-is-its-result.md), `spec/run-state.md` `RN-R36`/`RN-R37`):
1438
+
1439
+ - **Nothing is handed to the scheduler** — the scheduler never sees the ask's id. The driver appends ONE fold input,
1440
+ `{ kind: 'asked', taskId, input }`; the fold reads the node `suspended` and the run `awaiting_action`, exactly the
1441
+ words a parked task gives (so `done` waits, below), and the node's entry in `nodes` carries **`input`** — what reached
1442
+ the ask, its `needs` composed — kept after the answer. Tell an ask from a parked failure by the node's KIND, or by the
1443
+ `asked` element, never by an error string. In a fan-out the ask's branch is LIVE, so **its join waits for the person**.
1444
+ - **The answer is `driver.resolve(taskId, …)`** — the same call as below. `'done'` with `result` the answer, held to the
1445
+ ask's `answerSchema` (refused as `BoundaryRefused` with NOTHING appended and the ask still parked — answer again;
1446
+ `validateBoundaries: false` turns it off); `'failed'` with an `error` is the person DECLINING, held to nothing.
1447
+ - **The answer IS the node's result.** It routes as a task's settlement does — `on: ['failed']` for a decline, a `when`
1448
+ over `outcome.result` — the successor RECEIVES it, and a later `needs` naming the ask reads it; the value that reached
1449
+ the ask stays reachable by naming whatever produced it — and where NOTHING produced it, an ask at the ENTRY, by
1450
+ `"$payload"`, the run's payload (since 0.8.0, section 3: `needs: { invoice: '$payload', answer: 'approve' }`); and where
1451
+ the ask is a MAP's branch, by `"$item"`, the instance's own item (`needs: { invoice: '$item', answer: 'review' }`). A
1452
+ `"$payload"` key in an ask's OWN `needs` puts a copy of the whole payload into the `asked` element, and so into the
1453
+ run's log, once per ask — that element keeps what reached the ask, whatever its size. A predicate's `ctx.outcome` for an ask has `attempts: 0` and
1454
+ **`schedulerTimestamp`, `startTime`, `endTime` and `executionTime` all `null`** — no scheduler was involved.
1455
+ - **BREAKING in 0.8.0** (P35 decision 8): `FoldInput` gains the kind `'asked'`, and `OutcomeRecord.schedulerTimestamp`
1456
+ is `number | null` — `null` only on an ask's effective outcome. `NodeRun.input` is optional and additive.
1457
+ - **An answer given from a callback on the driver's own step** — `onFoldInput` on the `asked` element included — is
1458
+ checked at once (a refusal is thrown or `false` returned right there) and, accepted, `resolve()` returns `true` and the
1459
+ resolution is appended when the step ends: so the run's checkpoint resumes. A second answer while it is pending is
1460
+ refused. **The same holds for ANY `resolve()` made from such a callback**, a parked task's included (since 0.8.0).
1461
+ - **On a resume** the replay re-enters the ask and REGENERATES its `asked` element — the same id, the same kept input,
1462
+ compared with what you stored as structured-clone data (a different one — a `Map` entry, a `Date` — is refused
1463
+ `diverged`) — and hands nothing to the new scheduler for it.
1464
+ - **Under run control an ask is a parked task** ([W181](doc/work-items/w181-an-ask-under-run-control.md), `RN-R38`, P35 (g)): it was never dispatched, so it
1465
+ is never in flight. A **cancel** sends the scheduler nothing for it and the run reads `cancelled` once the tasks it did
1466
+ cancel have stopped — at once if nothing runs; a **stop** sends nothing for it and refuses its answer on that runtime,
1467
+ and the resume elsewhere re-parks it and takes the answer there; a **pause** holds nothing for it, and its answer is
1468
+ accepted while paused, whatever it routes to recorded and HELD until you unpause (section 8). A run waiting only on
1469
+ asks is not refused `ended` by any of them.
1470
+
1471
+ ```ts run
1472
+ import { BoundaryRefused, Driver, fold, liveToken, type FlowDefinition } from 'fyflow-orchestration';
1473
+
1474
+ // A scheduler double, section 12's shape: it RECORDS what it is handed and fires only what we tell it to.
1475
+ const added: string[] = [];
1476
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1477
+ const scheduler = {
1478
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
1479
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
1480
+ };
1481
+ const done = (id: string, result: unknown) => {
1482
+ const detail = { id, state: 'done', result, attempts: 0, timestamp: 1, startTime: 1, endTime: 2, executionTime: 1 };
1483
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail });
1484
+ };
1485
+
1486
+ const flow: FlowDefinition = {
1487
+ flowId: 'publish-review', version: 1, entry: 'draft',
1488
+ nodes: [
1489
+ { id: 'draft', kind: 'task', type: 'Draft', edges: [{ to: 'approve' }] },
1490
+ { id: 'approve', kind: 'ask', settings: { question: 'Publish this draft?' },
1491
+ answerSchema: { type: 'object', required: ['approved'], properties: { approved: { type: 'boolean' } } },
1492
+ edges: [
1493
+ { on: ['failed'], to: 'declined' },
1494
+ { when: { path: 'outcome.result.approved', op: 'eq', value: true }, to: 'publish' },
1495
+ { to: 'declined' },
1496
+ ] },
1497
+ { id: 'publish', kind: 'task', type: 'Publish', edges: [{ to: 'end' }] },
1498
+ { id: 'declined', kind: 'end', outcome: 'declined' },
1499
+ { id: 'end', kind: 'end', outcome: 'completed' },
1500
+ ],
1501
+ };
1502
+ const driver = new Driver(flow, {
1503
+ runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken,
1504
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { Draft: { worker: 'w' }, Publish: { worker: 'w' } },
1505
+ });
1506
+ driver.start({ topic: 'release notes' });
1507
+ done('r1:draft:-:0', { text: 'a draft' });
1508
+
1509
+ // The run PARKED at the ask: nothing handed to the scheduler for it, the draft it was shown KEPT.
1510
+ const parked = fold(driver.foldInputs());
1511
+ console.log(added, parked.outcome, parked.nodes['r1:approve:-:0'].input);
1512
+ // [ 'r1:draft:-:0' ] awaiting_action { text: 'a draft' }
1513
+ if (added.join() !== 'r1:draft:-:0' || parked.outcome !== 'awaiting_action') throw new Error('the ask should park with nothing dispatched');
1514
+ if (JSON.stringify(parked.nodes['r1:approve:-:0'].input) !== '{"text":"a draft"}') throw new Error('the ask keeps what reached it');
1515
+
1516
+ // An answer its schema refuses is thrown at you BY NAME — nothing appended, the ask still parked.
1517
+ let refused: unknown = null;
1518
+ try { driver.resolve('r1:approve:-:0', 'done', { result: { approved: 'yes' }, actor: 'ana' }); } catch (e) { refused = e; }
1519
+ if (!(refused instanceof BoundaryRefused)) throw new Error(`a nonconforming answer is BoundaryRefused, got ${String(refused)}`);
1520
+ if (fold(driver.foldInputs()).nodes['r1:approve:-:0'].state !== 'suspended') throw new Error('and the ask is still parked');
1521
+
1522
+ // The corrected answer IS the node's result: the `when` reads it, and `publish` is dispatched.
1523
+ if (!driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' })) throw new Error('the answer was refused');
1524
+ const outcome = driver.effectiveOutcomeOf('r1:approve:-:0');
1525
+ console.log(added, outcome?.attempts, outcome?.schedulerTimestamp); // [ 'r1:draft:-:0', 'r1:publish:-:0' ] 0 null
1526
+ if (added.join() !== 'r1:draft:-:0,r1:publish:-:0') throw new Error('approved routes to publish');
1527
+ if (outcome?.attempts !== 0 || outcome.schedulerTimestamp !== null) throw new Error('an ask`s outcome has no scheduler fields');
1528
+ ```
1529
+
1530
+ **Stopped, resumed elsewhere, and answered while paused** — the same flow, its run stopped while it waits on the person:
1531
+
1532
+ ```ts run
1533
+ import { Driver, fold, liveToken, type FlowDefinition } from 'fyflow-orchestration';
1534
+
1535
+ // A scheduler double per "runtime": it records what it is handed and cancelled, and fires only what we tell it to.
1536
+ function schedulerDouble() {
1537
+ const added: string[] = [];
1538
+ const cancelled: string[] = [];
1539
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1540
+ return {
1541
+ added, cancelled,
1542
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
1543
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
1544
+ cancel(id: string) { cancelled.push(id); return Promise.resolve(true); },
1545
+ done(id: string, result: unknown) {
1546
+ const detail = { id, state: 'done', result, attempts: 0, timestamp: 1, startTime: 1, endTime: 2, executionTime: 1 };
1547
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail });
1548
+ },
1549
+ };
1550
+ }
1551
+ const flow: FlowDefinition = {
1552
+ flowId: 'publish-review', version: 1, entry: 'draft',
1553
+ nodes: [
1554
+ { id: 'draft', kind: 'task', type: 'Draft', edges: [{ to: 'approve' }] },
1555
+ { id: 'approve', kind: 'ask', edges: [{ to: 'publish' }] },
1556
+ { id: 'publish', kind: 'task', type: 'Publish', edges: [{ to: 'end' }] },
1557
+ { id: 'end', kind: 'end', outcome: 'completed' },
1558
+ ],
1559
+ };
1560
+ const driverOn = (scheduler: ReturnType<typeof schedulerDouble>) => new Driver(flow, {
1561
+ runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken,
1562
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { Draft: { worker: 'w' }, Publish: { worker: 'w' } },
1563
+ });
1564
+
1565
+ // RUNTIME A: the draft settles, the run parks at the ask — and is STOPPED. Nothing is sent for the ask.
1566
+ const a = schedulerDouble();
1567
+ const first = driverOn(a);
1568
+ first.start({ topic: 'release notes' });
1569
+ a.done('r1:draft:-:0', { text: 'a draft' });
1570
+ await first.stop({ actor: 'ops', reason: 'upgrade' });
1571
+ console.log(a.cancelled, fold(first.foldInputs()).outcome); // [] interrupted
1572
+ if (a.cancelled.length !== 0) throw new Error('a stop sends nothing for an ask: it is not in flight');
1573
+ if (first.resolve('r1:approve:-:0', 'done', { result: { ok: true }, actor: 'ana' })) throw new Error('the answer is refused on the stopped runtime');
1574
+
1575
+ // RUNTIME B: the checkpoint resumed — the ask RE-PARKED with what it was shown, nothing handed on for it.
1576
+ const b = schedulerDouble();
1577
+ const second = driverOn(b);
1578
+ second.resume(first.checkpoint());
1579
+ console.log(b.added, fold(second.foldInputs()).nodes['r1:approve:-:0'].input); // [] { text: 'a draft' }
1580
+ if (b.added.length !== 0 || fold(second.foldInputs()).outcome !== 'awaiting_action') throw new Error('re-parked, nothing handed on');
1581
+
1582
+ // PAUSED, the answer is accepted — and what it routes to is recorded and HELD until the unpause.
1583
+ second.pause({ actor: 'ops' });
1584
+ if (!second.resolve('r1:approve:-:0', 'done', { result: { ok: true }, actor: 'ana' })) throw new Error('answered while paused');
1585
+ if (b.added.length !== 0) throw new Error('publish is held while paused');
1586
+ second.unpause({ actor: 'ops' });
1587
+ console.log(b.added); // [ 'r1:publish:-:0' ]
1588
+ if (b.added.join() !== 'r1:publish:-:0') throw new Error('the unpause hands publish on');
1589
+ b.done('r1:publish:-:0', 'published');
1590
+ if (fold(second.foldInputs()).outcome !== 'completed') throw new Error('the run completes on runtime B');
1591
+ ```
1592
+
1593
+ **A flow that waits for a person because a task FAILED** is the parked task below — the road an ask replaces for a
1594
+ question asked by design, and still the road for a task out of retries.
1595
+
1016
1596
  > **A `user_action` task fires TWO events, and the first one is called `task.failed`.** The scheduler
1017
1597
  > sets `state = 'user_action'` and then dispatches `task.failed` *and* `task.user_action` carrying
1018
1598
  > the same object. **Never key on the event name alone**: that first `task.failed` is for a task
@@ -1020,7 +1600,9 @@ keeps waiting, and the run is not stuck.
1020
1600
  > human-intervention case down an error edge. The driver drops it and records on the second — it
1021
1601
  > matters if you subscribe to the scheduler yourself.
1022
1602
  > [ADR 0007](doc/decisions/0007-the-driver-normalizes-events-into-an-immutable-outcome-record.md)
1023
- > rule 2; the scheduler's own reference does not document it for the ordinary path.
1603
+ > rule 2. The scheduler's own reference records the pair in its per-task event order —
1604
+ > *"`task.failed` (plus `task.user_action`)"* — while its task-states paragraph still says *"`task.failed`
1605
+ > fires once per failed task"*; trust the pair (re-read at the pin by W157 and W158).
1024
1606
 
1025
1607
  ```ts run
1026
1608
  import { createRuntime } from 'fyflow-orchestration/scheduler';
@@ -1056,7 +1638,7 @@ const { driver, done } = runtime.start(flow, {}, { onFoldInput: (input) => { inp
1056
1638
 
1057
1639
  // **`done` IS STILL PENDING HERE, and that is the trap this section exists to teach.** Its
1058
1640
  // settlement rule is terminality and nothing else (ADR 0058 rule 3), and `isTerminal` is
1059
- // `outcome !== 'running' && outcome !== 'awaiting_action'` — so a run parked on a person is not
1641
+ // `outcome !== 'running' && outcome !== 'awaiting_action' && outcome !== 'cancelling'` — so a run parked on a person is not
1060
1642
  // terminal and `done` waits for the resolution. **A promise cannot tell you a run has PARKED**,
1061
1643
  // because parking is not the end; the poll below is for the state `done` is waiting THROUGH, and it
1062
1644
  // is the one thing here a completion handle does not give you.
@@ -1133,9 +1715,10 @@ if (state.pathEnds.map((p) => p.at).join() !== 'quarantine') {
1133
1715
  ```
1134
1716
 
1135
1717
  `resolve()` returns `false` if the task is not suspended — a rejected command, not a run failure, so
1136
- you may retry. It does **one** thing: supply an outcome for a dispatch that already happened. It
1137
- cannot re-run the node and it cannot send it somewhere else. It also returns `false` once the run has
1138
- been **stopped** by a refusal (section 9), and there retrying can never succeed: check
1718
+ you may retry. It does **one** thing: supply an outcome for a dispatch that already happened — or, for
1719
+ an ask, the answer to a node that dispatched nothing (above). It cannot re-run the node and it cannot send it
1720
+ somewhere else. It also returns `false` once the run has
1721
+ been **halted** by a refusal (section 9), and there retrying can never succeed: check
1139
1722
  `driver.hasFailed()` to tell the two `false`s apart.
1140
1723
 
1141
1724
  A resolved node is **indistinguishable** from one that settled that way: same node state, same
@@ -1154,7 +1737,7 @@ into state and touches nothing else.
1154
1737
  ```ts run
1155
1738
  import { fold, isTerminal, type FoldInput } from 'fyflow-orchestration';
1156
1739
 
1157
- const settled = (taskId: string, state: 'done' | 'failed' | 'user_action'): FoldInput => ({
1740
+ const settled = (taskId: string, state: 'done' | 'failed' | 'user_action' | 'cancelled'): FoldInput => ({
1158
1741
  kind: 'outcome',
1159
1742
  record: { kind: 'outcome', taskId, terminalState: state, result: null, error: null,
1160
1743
  attempts: 0, schedulerTimestamp: 1, observedAt: 1, startTime: 1, endTime: 2, executionTime: 1 },
@@ -1179,10 +1762,30 @@ const rows: [string, FoldInput[]][] = [
1179
1762
  ['awaiting_action', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1180
1763
  ended('no-eligible-edge'), openJoin, dispatched('r1:b:-:0'),
1181
1764
  dispatched('r1:c:-:0'), settled('r1:c:-:0', 'user_action')]],
1182
- ['stopped', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1765
+ // The run's OWNER stops it to resume it elsewhere (section 18) while `b` is still in flight: `interrupted`.
1766
+ ['interrupted', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1767
+ ended('no-eligible-edge'), openJoin, dispatched('r1:b:-:0'),
1768
+ dispatched('r1:c:-:0'), settled('r1:c:-:0', 'user_action'),
1769
+ { kind: 'interrupted', actor: 'ops', reason: null }]],
1770
+ // The OWNER cancels it (section 6) while `b` is still running: `cancelling`. (The driver refuses a cancel after a
1771
+ // stop, but a fold must answer every sequence.)
1772
+ ['cancelling', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1773
+ ended('no-eligible-edge'), openJoin, dispatched('r1:b:-:0'),
1774
+ dispatched('r1:c:-:0'), settled('r1:c:-:0', 'user_action'),
1775
+ { kind: 'interrupted', actor: 'ops', reason: null },
1776
+ { kind: 'cancelRequested', actor: 'ops', reason: null }]],
1777
+ // ... and `b`, the last task in flight, has stopped: `cancelled`.
1778
+ ['cancelled', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1779
+ ended('no-eligible-edge'), openJoin, dispatched('r1:b:-:0'),
1780
+ dispatched('r1:c:-:0'), settled('r1:c:-:0', 'user_action'),
1781
+ { kind: 'interrupted', actor: 'ops', reason: null },
1782
+ { kind: 'cancelRequested', actor: 'ops', reason: null }, settled('r1:b:-:0', 'cancelled')]],
1783
+ ['halted', [dispatched('r1:a:-:0'), settled('r1:a:-:0', 'done'), ended('end'),
1183
1784
  ended('no-eligible-edge'), openJoin, dispatched('r1:b:-:0'),
1184
1785
  dispatched('r1:c:-:0'), settled('r1:c:-:0', 'user_action'),
1185
- { kind: 'stopped', reason: 'DuplicateTaskId: x' }]],
1786
+ { kind: 'interrupted', actor: 'ops', reason: null },
1787
+ { kind: 'cancelRequested', actor: 'ops', reason: null }, settled('r1:b:-:0', 'cancelled'),
1788
+ { kind: 'halted', reason: 'DuplicateTaskId: x' }]],
1186
1789
  ];
1187
1790
  for (const [expected, inputs] of rows) {
1188
1791
  const got = fold(inputs).outcome;
@@ -1190,14 +1793,16 @@ for (const [expected, inputs] of rows) {
1190
1793
  if (got !== expected) throw new Error(`precedence: expected ${expected}, got ${got}`);
1191
1794
  }
1192
1795
 
1193
- // `isTerminal` is false for exactly `running` and `awaiting_action`.
1796
+ // `isTerminal` is false for exactly `running`, `awaiting_action` and `cancelling`.
1194
1797
  const terminal = Object.fromEntries(rows.map(([o, i]) => [o, isTerminal(fold(i))]));
1195
- console.log(terminal); // completed/failed/stuck/stopped true, running/awaiting_action false
1798
+ console.log(terminal); // completed/failed/stuck/interrupted/cancelled/halted true, running/awaiting_action/cancelling false
1196
1799
  if (!terminal.stuck) throw new Error('`stuck` is terminal — that is the point of it existing');
1197
1800
  if (terminal.running || terminal.awaiting_action) throw new Error('a live run is not terminal');
1198
- if (!terminal.completed || !terminal.failed || !terminal.stopped) {
1199
- throw new Error('completed, failed and stopped are all terminal');
1801
+ if (terminal.cancelling) throw new Error('a run whose tasks are still stopping is not terminal');
1802
+ if (!terminal.completed || !terminal.failed || !terminal.cancelled || !terminal.halted) {
1803
+ throw new Error('completed, failed, cancelled and halted are all terminal');
1200
1804
  }
1805
+ if (!terminal.interrupted) throw new Error('a stopped run is terminal HERE — a resume elsewhere continues it');
1201
1806
 
1202
1807
  // `completed` means every live path reached an `end` — NOT "no node settled failed".
1203
1808
  // A failed node that routed on through an `on: ["failed"]` edge completes the run.
@@ -1216,11 +1821,14 @@ const state = fold(driver.foldInputs());
1216
1821
  if (isTerminal(state)) console.log('finished as', state.outcome);
1217
1822
  ```
1218
1823
 
1219
- ### The six outcomes, highest first
1824
+ ### The nine outcomes, highest first
1220
1825
 
1221
1826
  | | means |
1222
1827
  |---|---|
1223
- | `stopped` | a refusal terminated the run — section 9 |
1828
+ | `halted` | a refusal terminated the run — section 9. *`stopped` until 0.8.0 (ADR 0102); a stored `stopped` element folds as this* |
1829
+ | **`cancelled`** | **a task of the run was cancelled by the scheduler on its own, and the driver halted it — or the run's owner cancelled it with `driver.cancel` and every task has stopped** — section 6. It outranks everything below it, a parked task and a waiting join included |
1830
+ | **`cancelling`** | **the run's owner cancelled it and a task is still stopping** — section 6, since 0.8.0. **NOT terminal**: `done` waits for the last task. It outranks everything below it |
1831
+ | **`interrupted`** | **the run's owner STOPPED it to resume it elsewhere** — section 18, since 0.8.0. Terminal on this runtime and the ONE terminal outcome a resume accepts; a `resumed` event ends it. It outranks everything below it, a parked task included |
1224
1832
  | `awaiting_action` | a node is suspended and nobody has resolved it |
1225
1833
  | `running` | a dispatched node has not settled |
1226
1834
  | **`stuck`** | nothing is live and **nothing ever will be**: a join has not fired and cannot |
@@ -1238,15 +1846,15 @@ rule 6). *(That phrase lived in §2's copied block until [W78](doc/work-items/w7
1238
1846
  reduced it to `await done`; ADR 0058's Context cites it, so it moved here rather than going out with
1239
1847
  the block — W78's conformity reader, finding 3.)*
1240
1848
 
1241
- **`isTerminal(state)` is how you wait for a run without a clock.** It is false for `running` and
1242
- `awaiting_action` and true for everything else — including `stuck`, which is the point: before
1849
+ **`isTerminal(state)` is how you wait for a run without a clock.** It is false for `running`,
1850
+ `awaiting_action` and `cancelling` and true for everything else — `interrupted` included, though a resume ELSEWHERE continues that run — and `stuck`, which is the point: before
1243
1851
  `stuck` existed, a permanently hung run folded to `completed` and a waiter reported success.
1244
1852
 
1245
1853
  **And `done` is that rule applied for you.** `runtime.start` returns `{ driver, done, header }` —
1246
1854
  `header` is the run's CHECKPOINT header since
1247
1855
  [ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md),
1248
1856
  and `driver.checkpoint()` hands out the header with every event so far (`spec/run-state.md`
1249
- section 8); `runtime.resume(flow, checkpoint)` resumes a run from one, returning the same handle
1857
+ section 8) — and, since 0.8.0, `start`'s `onHeader` option hands you the same header BEFORE the first event (section 18); `runtime.resume(flow, checkpoint)` resumes a run from one, returning the same handle
1250
1858
  ([ADR 0065](doc/decisions/0065-a-resume-replays-the-stored-stimuli-refuses-what-it-cannot-regenerate-and-re-dispatches-what-was-in-flight.md), `spec/run-state.md` section 9 — section 18 teaches it, as an executed snippet) — and `done`
1251
1859
  resolves with the first `RunState` this predicate calls terminal
1252
1860
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md)). It
@@ -1255,9 +1863,14 @@ is the same two functions — the runtime keeps one `RunState`, steps it per inp
1255
1863
  nothing above about reading at rest stops being true. **Three things follow, and two of them catch
1256
1864
  people:**
1257
1865
 
1258
- - **`done` never rejects.** `failed`, `stuck` and `stopped` are terminal OUTCOMES, not errors, so a
1259
- run that goes wrong arrives as `state.outcome`. A driver refusal resolves it with `stopped` and
1260
- `stoppedBy` naming the refusal — section 9.
1866
+ - **`done` never rejects.** `failed`, `stuck`, `cancelled` and `halted` are terminal OUTCOMES, not
1867
+ errors, so a run that goes wrong arrives as `state.outcome`. A driver refusal resolves it with
1868
+ `halted` and `haltedBy` naming the refusal — section 9; a task the scheduler cancelled on its own
1869
+ resolves it with `cancelled` on the FIRST cancel, while the run's other tasks may still be running —
1870
+ section 6 — and `driver.cancel` resolves it with `cancelled` only once its LAST task has stopped,
1871
+ the run reading `cancelling` until then. A run its owner STOPPED (`handle.stop()`, section 18)
1872
+ resolves it with `interrupted` once the stop's cancels have resolved — not at the stop. **Either resolves it with
1873
+ `halted` AT ONCE if your callback threw on the `cancelRequested` or `interrupted` element** (section 9).
1261
1874
  - **`awaiting_action` is NOT terminal, so `done` waits through it.** A run parked on a person stays
1262
1875
  pending until somebody calls `resolve()`, which is correct and is the trap: if you are waiting for
1263
1876
  a run and a human is in it, you are waiting for the human. Section 7 is the worked case.
@@ -1265,6 +1878,219 @@ people:**
1265
1878
  `Promise.race([done, timeout])` is your one line — and a library default would be wrong for the
1266
1879
  bullet above.
1267
1880
 
1881
+ ### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
1882
+
1883
+ **`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
1884
+ [ADR 0106](doc/decisions/0106-a-run-its-owner-pauses-routes-on-and-holds-every-dispatch-until-unpaused.md)). While it
1885
+ is paused the run still ROUTES — a settlement's successor is chosen, a join settles, `resolve()` is accepted — and every
1886
+ task it dispatches is RECORDED and HELD: its `dispatched` event is in the log, and nothing new reaches the scheduler.
1887
+ The tasks the scheduler already has run on and finish. The unpause hands the held tasks on **in the order they were
1888
+ dispatched**. Each call is ONE fold input, `{ kind: 'paused', actor }` or `{ kind: 'unpaused', actor }`.
1889
+
1890
+ **The fold reads `state.paused` — a FLAG, NOT one of the nine outcomes above**: a held task is dispatched, so it reads
1891
+ `running`, and so does the run. Nothing about `outcome`, `isTerminal` or `done` changes; a paused run whose tasks all
1892
+ finish and whose paths all end still `completed`s. **And `paused` can read `true` on a run that has ENDED**: only an
1893
+ unpause clears it, and a cancelled or halted run refuses one — read `outcome` first, and `paused` only on a live run.
1894
+
1895
+ | | what happens |
1896
+ |---|---|
1897
+ | a task the scheduler already has | runs on and settles, and its settlement routes as ever — what it routes to is held |
1898
+ | `resolve()` on a parked task | accepted; its successor is held |
1899
+ | an ASK's answer (section 7) | the same: accepted, and whatever it routes to is recorded and HELD until the unpause. Nothing is held FOR the ask — it was never going to be handed on ([W181](doc/work-items/w181-an-ask-under-run-control.md), `RN-R38`) |
1900
+ | a call from your own callback, mid-step | accepted, and taken when the driver's step ends — so that step's dispatches are handed on. **Pause before the settlement whose successor you want held** |
1901
+ | `handle.stop()` / `driver.stop()` of a paused run | cancels only what the scheduler has; a held task is sent nothing and stays in flight — and the checkpoint resumes **PAUSED** elsewhere, EVERY task in flight held until you unpause there, the ones that were running at the stop included (section 18) |
1902
+ | `driver.cancel()` of a paused run | hands each held task on and cancels it, so the scheduler settles every one — `cancelling`, then `cancelled` (section 6) |
1903
+ | refused | `RunControlRefused`, `control: 'pause'` or `'unpause'`, by `reason`: `not-started`, `halted`, `cancelled`, `interrupted` (a stopped run — pause it where it resumes), `paused` (a second pause), `not-paused` (an unpause of a run not paused), and for a pause only `ended` — nothing recorded |
1904
+ | your `onFoldInput` THROWS on the `paused` or `unpaused` element | the element is in the log — an unpause's held tasks still handed on, in a `finally` — and then the run is HALTED and reported once, and the throw reaches the caller of `pause()` or `unpause()`, or of the call whose step took it; on a scheduler event's step nothing is thrown (section 9, `RN-R35`). *Until [W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md) a throw on your own call's step reached you with the run going on, paused or running* |
1905
+
1906
+ ```ts run
1907
+ import { Driver, fold, liveToken, type FlowDefinition } from 'fyflow-orchestration';
1908
+
1909
+ const flow: FlowDefinition = {
1910
+ flowId: 'pause-run', version: 1, entry: 'split',
1911
+ nodes: [
1912
+ { id: 'split', kind: 'fanout', branches: ['a', 'b'] },
1913
+ { id: 'a', kind: 'task', type: 'T', edges: [{ to: 'a2' }] },
1914
+ { id: 'b', kind: 'task', type: 'T', edges: [{ to: 'b2' }] },
1915
+ { id: 'a2', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1916
+ { id: 'b2', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
1917
+ { id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'done' }] },
1918
+ { id: 'done', kind: 'end', outcome: 'completed' },
1919
+ ],
1920
+ };
1921
+
1922
+ // A fake scheduler: it records what it is handed, and a test says when a task completes.
1923
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1924
+ const added: string[] = [];
1925
+ const scheduler = {
1926
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
1927
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
1928
+ };
1929
+ const completes = (id: string) => {
1930
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail: { id, state: 'done', result: { ok: true } } });
1931
+ };
1932
+
1933
+ const driver = new Driver(flow, {
1934
+ runId: 'run-1', scheduler, createTask: (c: unknown) => c, predicates: new Map(),
1935
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } }, joinPolicy: liveToken,
1936
+ });
1937
+ driver.start({});
1938
+ driver.pause({ actor: 'ops' }); // a and b are already with the scheduler: they run on
1939
+ completes('run-1:b:1:0'); // b2 is routed, RECORDED and HELD
1940
+ completes('run-1:a:0:0'); // and a2
1941
+ console.log(added); // [ 'run-1:a:0:0', 'run-1:b:1:0' ] — nothing new handed on
1942
+ const paused = fold(driver.foldInputs());
1943
+ console.log(paused.outcome, paused.paused); // running true — a held task is in flight, not finished
1944
+ if (added.length !== 2 || paused.outcome !== 'running' || !paused.paused) throw new Error('held, recorded, not handed on');
1945
+
1946
+ driver.unpause({ actor: 'ops' });
1947
+ console.log(added.slice(2)); // [ 'run-1:b2:1:0', 'run-1:a2:0:0' ] — in the order they were dispatched
1948
+ if (added.slice(2).join() !== 'run-1:b2:1:0,run-1:a2:0:0') throw new Error('handed on in dispatch order');
1949
+ completes('run-1:b2:1:0');
1950
+ completes('run-1:a2:0:0');
1951
+ const state = fold(driver.foldInputs());
1952
+ if (state.outcome !== 'completed' || state.paused) throw new Error('unpaused, and the run completed');
1953
+ ```
1954
+
1955
+ **BREAKING in 0.8.0** (P34 decision 10): `FoldInput` gains the kinds `'paused'` and `'unpaused'`, so an exhaustive
1956
+ `switch` over `kind` no longer compiles; `RunState` gains the cell `paused`, which a `RunState` literal must carry;
1957
+ `RunControlRefused.control` gains `'pause'` and `'unpause'` and its `reason` `'paused'` and `'not-paused'`.
1958
+ `RunOutcome` does not move.
1959
+
1960
+ ### Whose task it is — the attribution on a `nodes` entry, and a task's messages and progress
1961
+
1962
+ **Every `nodes` entry says which run and which attempt it is, as FIELDS** (`RN-R41`,
1963
+ [ADR 0114](doc/decisions/0114-a-tasks-attribution-is-fields-and-its-messages-and-progress-are-passed-through.md)): `runId`
1964
+ and `flowAttempt` are the task id's first and fourth segments — `flowAttempt` is `0` on every id minted today — and
1965
+ `attemptNumber` is the SCHEDULER's 1-based number of the attempt the task is on or settled on, from the latest `attempted`
1966
+ event (section 18). **`attemptNumber` is `null`** before the task's first attempt starts, on an ASK (never handed to a
1967
+ scheduler), and on every entry of a log written before 0.8.0 — it is never read off a settlement's `attempts`. Two names,
1968
+ so the two attempts cannot be confused.
1969
+
1970
+ **And a task's `context.emit()` and progress reach you through the run, with the same attribution** (`RN-R42`):
1971
+ `onTaskMessage(m)` and `onTaskProgress(p)` on the `Driver`'s options or `runtime.start`'s (and `runtime.resume`'s). `m`
1972
+ carries `runId`, `nodeId`, `branchPath`, `taskId`, `flowAttempt`, `attemptNumber` and the scheduler's `name`, `data`,
1973
+ `seq`, `timestamp` — `dropped` and `error` only when the scheduler set them; `p` the same attribution with `progress`,
1974
+ `message`, `details` and `timestamp`, and NONE of the task's own fields. Each is a FROZEN copy that is yours.
1975
+
1976
+ | | what happens |
1977
+ |---|---|
1978
+ | you pass neither | the driver does not subscribe `task.message` or `task.progress` — and the scheduler sends nothing while nobody listens, so your workers' emits cost nothing |
1979
+ | an event for another run's task, an id no driver minted, or a task this run has not dispatched | dropped in silence — nothing reported, nothing halted. On a runtime ANOTHER run's task never even reaches this run's driver: the router reads `taskId` where the detail has no string `id`. An id that does not parse reaches every driver that asked (each drops it), and an undispatched task of THIS run reaches this run's driver, which drops it |
1980
+ | after `stop()`, or once a runtime has released the run (its `done` settled) | nothing more is delivered. A HALTED run's tasks still run, and their events still arrive |
1981
+ | a value that cannot be cloned (a function in an inline worker's `details`) | handed to you as `undefined` — the scheduler does the same with an `emit` it cannot clone. **You cannot tell it from a worker that gave nothing**: no marker says the clone failed (a message's `error` is set only when the SCHEDULER refused the clone) |
1982
+ | your callback THROWS | reported once through `onRunFailure` and the run HALTED — the treatment `onUnmintedTask` gets (section 9) |
1983
+ | the fold, the checkpoint, the routing | untouched: neither callback is ever a fold input |
1984
+
1985
+ ```ts run
1986
+ import { Driver, fold, type FlowDefinition, type TaskMessage, type TaskProgress } from 'fyflow-orchestration';
1987
+
1988
+ const flow: FlowDefinition = {
1989
+ flowId: 'attributed', version: 1, entry: 'a',
1990
+ nodes: [
1991
+ { id: 'a', kind: 'task', type: 'T', retryPolicy: { maxRetries: 1, backoffMs: 0 }, edges: [{ to: 'done' }] },
1992
+ { id: 'done', kind: 'end', outcome: 'completed' },
1993
+ ],
1994
+ };
1995
+
1996
+ // A fake scheduler: it records what the driver SUBSCRIBED to, and a test fires the scheduler's events by hand.
1997
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
1998
+ const subscribed: string[] = [];
1999
+ const scheduler = {
2000
+ addTask() {},
2001
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) {
2002
+ subscribed.push(type);
2003
+ listeners.set(type, [...(listeners.get(type) ?? []), fn]);
2004
+ },
2005
+ };
2006
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
2007
+
2008
+ const messages: TaskMessage[] = [];
2009
+ const progress: TaskProgress[] = [];
2010
+ const driver = new Driver(flow, {
2011
+ runId: 'run-1', scheduler, createTask: (c: unknown) => c, predicates: new Map(),
2012
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } },
2013
+ onTaskMessage: (m) => { messages.push(m); },
2014
+ onTaskProgress: (p) => { progress.push(p); },
2015
+ });
2016
+ if (!subscribed.includes('task.message') || !subscribed.includes('task.progress')) throw new Error('subscribed, because you asked');
2017
+ driver.start({});
2018
+
2019
+ fire('task.running', { id: 'run-1:a:-:0', state: 'running', attemptNumber: 1 });
2020
+ fire('task.running', { id: 'run-1:a:-:0', state: 'running', attemptNumber: 2 }); // the retry starts
2021
+ // `task.message` names its task `taskId`; `task.progress` is the task spread flat, so its id is `id`.
2022
+ fire('task.message', { taskId: 'run-1:a:-:0', workerId: 'w1', attemptNumber: 2, name: 'log', data: { line: 'fetched 3 rows' }, seq: 0, timestamp: 1 });
2023
+ fire('task.message', { taskId: 'run-2:a:-:0', workerId: 'w1', attemptNumber: 1, name: 'log', data: {}, seq: 0, timestamp: 1 }); // another run's
2024
+ fire('task.progress', { id: 'run-1:a:-:0', state: 'running', attemptNumber: 2, workerType: 'inline', progress: 0.5, message: 'half', details: null, timestamp: 2 });
2025
+ // Messages are BATCHED, so attempt 1's last line can arrive after attempt 2 started: it is attributed to attempt 1.
2026
+ fire('task.message', { taskId: 'run-1:a:-:0', workerId: 'w1', attemptNumber: 1, name: 'log', data: { line: 'retrying' }, seq: 1, timestamp: 1 });
2027
+
2028
+ const m = messages[0]!;
2029
+ console.log(messages.length, m.runId, m.nodeId, m.flowAttempt, m.attemptNumber, m.name, m.data); // 2 run-1 a 0 2 log { line: 'fetched 3 rows' }
2030
+ if (messages.length !== 2 || m.attemptNumber !== 2 || !Object.isFrozen(m)) throw new Error('this run`s messages alone, attributed, frozen');
2031
+ if (messages[1]!.attemptNumber !== 1) throw new Error('a late message carries the attempt that EMITTED it, not the latest');
2032
+ if (progress.length !== 1 || progress[0]!.progress !== 0.5 || 'workerType' in progress[0]!) throw new Error('the report, not the task');
2033
+
2034
+ const entry = fold(driver.foldInputs()).nodes['run-1:a:-:0']!;
2035
+ console.log(entry.runId, entry.flowAttempt, entry.attemptNumber); // run-1 0 2
2036
+ if (entry.runId !== 'run-1' || entry.flowAttempt !== 0 || entry.attemptNumber !== 2) throw new Error('the entry carries its attribution — 2, beside the late message`s 1');
2037
+ if (driver.foldInputs().length !== 3) throw new Error('dispatched and two attempts — nothing for the message or the progress');
2038
+ ```
2039
+
2040
+ **A worker's OWN messages — `worker.message`, outside any run.** A worker's constructor gets the scheduler's worker
2041
+ context as its second argument, and `workerContext.emit(name, data)` sends a `worker.message` on the worker's POOL —
2042
+ for what belongs to no task, such as model-loading progress from `setup()`. The library passes these through to
2043
+ nothing: they have no run, node or attempt to attribute. Listen on the pool yourself. **The pools are reachable
2044
+ through `createRuntime`'s `createScheduler` option**, which is handed the `WorkerManager`s keyed by pool name before
2045
+ any task is added — so a listener added there misses nothing. (`runtime.pools` is the DERIVED description of each
2046
+ pool, not the `WorkerManager`, and has no events.)
2047
+
2048
+ ```ts run
2049
+ import { createRuntime } from 'fyflow-orchestration/scheduler';
2050
+ import { type FlowDefinition, type RegistryDocument } from 'fyflow-orchestration';
2051
+ import { FyflowScheduler } from 'fyflow-scheduler';
2052
+
2053
+ // A worker that keeps its constructor's second argument and emits from `setup()`, where there is no task.
2054
+ const loader = `data:text/javascript,${encodeURIComponent(`export default class Loader {
2055
+ constructor(config, workerContext) { this.ctx = workerContext; }
2056
+ async setup() { this.ctx.emit('loading', { pct: 50 }); this.ctx.emit('loading', { pct: 100 }); }
2057
+ run(envelope) { return envelope.input; } }`)}`;
2058
+
2059
+ const registry: RegistryDocument = { workers: { loader: { url: loader } }, nodeTypes: { Model: { worker: 'loader', inline: true } } };
2060
+ const flow: FlowDefinition = {
2061
+ flowId: 'loads', version: 1, entry: 'm',
2062
+ nodes: [{ id: 'm', kind: 'task', type: 'Model', edges: [{ to: 'done' }] }, { id: 'done', kind: 'end', outcome: 'completed' }],
2063
+ };
2064
+
2065
+ // `createScheduler` is handed every pool by name, and returns the scheduler the runtime will use.
2066
+ const seen: { pool: string; name: string; data: unknown; seq: number }[] = [];
2067
+ const runtime = createRuntime(registry, {}, {
2068
+ base: import.meta.url,
2069
+ createScheduler: (pools, groups) => {
2070
+ for (const [pool, manager] of Object.entries(pools)) {
2071
+ manager.addEventListener('worker.message', (e) => {
2072
+ const d = (e as CustomEvent<{ name: string; data: unknown; seq: number }>).detail;
2073
+ seen.push({ pool, name: d.name, data: d.data, seq: d.seq });
2074
+ });
2075
+ }
2076
+ return new FyflowScheduler(pools, groups);
2077
+ },
2078
+ });
2079
+ const state = await runtime.start(flow, { ok: true }).done;
2080
+ await runtime.shutdown();
2081
+ console.log(seen); // [ { pool: 'Model', name: 'loading', data: { pct: 50 }, seq: 0 }, { …, data: { pct: 100 }, seq: 1 } ]
2082
+
2083
+ // Verified by the docs gate:
2084
+ const want = JSON.stringify([{ pool: 'Model', name: 'loading', data: { pct: 50 }, seq: 0 }, { pool: 'Model', name: 'loading', data: { pct: 100 }, seq: 1 }]);
2085
+ if (state.outcome !== 'completed') throw new Error(`expected completed, got ${state.outcome}`);
2086
+ if (JSON.stringify(seen) !== want) throw new Error(`the pool delivered ${JSON.stringify(seen)}`);
2087
+ ```
2088
+
2089
+ **BREAKING in 0.8.0** (P36 (j), (p)): `NodeRun` gains three REQUIRED fields — `runId`, `flowAttempt` and `attemptNumber` —
2090
+ so code that builds a `NodeRun` literal (a test's expected entry, a hand-made `RunState`) no longer compiles until it
2091
+ carries them. Reading an entry is unaffected. `onTaskMessage`, `onTaskProgress` and the types `TaskMessage`,
2092
+ `TaskProgress` and `TaskAttribution` are additive.
2093
+
1268
2094
  ---
1269
2095
 
1270
2096
  ## 9. Refusals
@@ -1272,7 +2098,8 @@ people:**
1272
2098
  Two kinds, and **the distinction is the stack, not the refusal class** (`OR-R10`). The same class can
1273
2099
  do either: whether you are on the stack when it is raised is what decides. A refusal raised while
1274
2100
  *your* call is running is thrown; one raised inside a scheduler event listener is reported, because
1275
- `EventTarget.dispatchEvent` **discards a listener throw** and there is nobody to catch it.
2101
+ `EventTarget.dispatchEvent` **never hands a listener throw back** — it resurfaces out of band, and in node
2102
+ and deno that is an UNCAUGHT exception that ends your process — and there is nobody to catch it.
1276
2103
 
1277
2104
  **Thrown**, because you are on the stack and can act:
1278
2105
 
@@ -1295,8 +2122,8 @@ console.log(thrown); // 'WorkerMissing'
1295
2122
  if (thrown !== 'WorkerMissing') throw new Error(`construction refused with ${thrown}`);
1296
2123
 
1297
2124
  // **REPORTED — raised inside a scheduler event listener.** Nobody is on the stack to catch it:
1298
- // `EventTarget.dispatchEvent` DISCARDS a listener throw, so the driver reports it instead and the
1299
- // run stops. This is the half a `try` around your own call can never see.
2125
+ // `EventTarget.dispatchEvent` never hands a listener throw back (node and deno raise it as an uncaught
2126
+ // exception), so the driver reports it instead and the run stops. This is the half a `try` around your own call can never see.
1300
2127
  const registry: RegistryDocument = {
1301
2128
  workers: { scorer: { url: '../../examples/getting-started/scorer.ts' } },
1302
2129
  nodeTypes: { Scorer: { worker: 'scorer', inline: true } },
@@ -1339,7 +2166,11 @@ if (reported.kind !== 'UnresolvedMapArity') throw new Error(`the run reported ${
1339
2166
  ```
1340
2167
 
1341
2168
  `InvalidDefinition` carries the violated rule ids on `.rules`, so you can act on them rather than
1342
- parse a string. `InvalidRunId` is thrown by the **constructor** for a `runId` outside `[A-Za-z0-9_-]{1,64}`
2169
+ parse a string. Its message is `definition "<flowId>" is invalid: ` then each finding as `<rule> at <at>: <detail>` — `at` a node id,
2170
+ an edge site (`id#edgeN`), or `<document>` for a finding with none — joined by `; `. It is built by ONE function,
2171
+ `InvalidDefinition.fromFindings(flowId, findings)`, which `new Driver` and a runtime's `start` and `resume` all call
2172
+ (W169), so the two roads cannot word one defect two ways; its precondition is at least one finding, and an empty list
2173
+ is refused with a `RangeError` rather than built into an error that names nothing. `InvalidRunId` is thrown by the **constructor** for a `runId` outside `[A-Za-z0-9_-]{1,64}`
1343
2174
  (`ID-R2`) — `:` is excluded because it is the task-id delimiter, and excluding it is what makes the
1344
2175
  task-id parse total.
1345
2176
 
@@ -1348,24 +2179,30 @@ task-id parse total.
1348
2179
  key, and a message naming the entry. `new Driver` runs the shape half only, six classes:
1349
2180
  `WorkerMissing` (a type naming no worker entry, an entry with no `url`), `PoolTypeMissing`,
1350
2181
  `NameCollision` (a pool named as a type), `SchemaRefused` (a keyword outside the subset), and
1351
- `UndeclaredKey` and `DerivationRefused` for a type's or a pool's field of the wrong shape. The other
2182
+ `UndeclaredKey` and `DerivationRefused` for a type's or a pool's field of the wrong shape (`DerivationRefused`'s
2183
+ other clauses — a derivation that throws, a resident group beside `idleTimeout: 0`, a built-in keyed group held
2184
+ resident — need a derivation run, and are `resolveRegistry`'s alone). The other
1352
2185
  five — `ConfigRefused`, `TypeDefaultRefused`, `GroupTypeMissing`, `GroupConfigRefused`,
1353
2186
  `NameMissing` — need a derivation run or a group built, and are thrown by
1354
2187
  `resolveRegistry(document, code)` when a registry DOCUMENT is resolved against the code that
1355
2188
  supplies its predicates, languages, group types and node-type classes, by `createRuntime` (which
1356
2189
  calls that resolver first — section 12), and by the bootstrap helper before it builds a pool. The
1357
2190
  twelfth, **`GateTypeRefused`** (v0.23), is thrown by `checkRegistry` when a gate type it holds is not
1358
- a `GateType` — the built-in `distinct` on every call, and any the registry adds in `gateTypes` — and a gate
2191
+ a `GateType` — the built-ins `distinct`, `if` and `switch` on every call, and any the registry adds in `gateTypes` — and a gate
1359
2192
  type named as a built-in is `NameCollision` — at `new Driver`, which since W93 takes yours as `gateTypes`, and
1360
2193
  at `createRuntime`, which takes them as `code.gateTypes` and hands them to every run (section 3). None is `InvalidDefinition`, on purpose: a registry defect is fixed in the registry and a flow defect in the
1361
2194
  flow, and the class says which document to open.
1362
2195
 
1363
2196
  **And four the RUNTIME adds, all `RuntimeError` subclasses from `fyflow-orchestration/scheduler`**
1364
2197
  (W66): `GroupUnusable` — a group factory returned something the scheduler cannot use, naming the
1365
- member and the group's key; `GroupNotBuildable` — a group type with no `create`; `LanguageNotBuilt` —
1366
- `start` was given a flow that transforms on a type no pool was built for; and, since W89, `RunIdInUse`
1367
- — a `start` or `resume` naming a run id that runtime has already built a driver for, a finished run's included (`RN-R25`, ADR 0091). A bare
1368
- `RuntimeError` is the fifth road: a relative worker url with no `base`, and a predicate or adapter supplied that the
2198
+ member and the group's key; `GroupNotBuildable` — a group type with no `create`; since W89, `RunIdInUse`
2199
+ — a `start` or `resume` naming a run id that runtime has already built a driver for, a finished run's included (`RN-R25`, ADR 0091);
2200
+ and, since W163, **`LimitKeyMissing`** — a `start` or `resume` given no `createTask`, of a flow a node of which reaches a
2201
+ group the built-in `KeyedRateLimitGroup` factory built (`FD-R6`, [ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md), section 12), its `at` the node.
2202
+ *(`LanguageNotBuilt` — a flow transforming on a type no pool was built for — was another until W162 removed it
2203
+ with `createRuntime`'s `flows`: every typed pool carries every registered language since
2204
+ [ADR 0096](doc/decisions/0096-a-runtime-is-its-registry-and-start-refuses-from-runtime-check.md), section 12.)* A bare
2205
+ `RuntimeError` is one more road: a relative worker url with no `base`, and a predicate or adapter supplied that the
1369
2206
  document names nowhere. **They are not exported from the package ROOT**, so the enumeration below —
1370
2207
  which walks the root's own exports — does not see them, and `BootstrapError` has never been there
1371
2208
  either.
@@ -1376,12 +2213,16 @@ reported, which is why the rule is about the stack:
1376
2213
  | | |
1377
2214
  |---|---|
1378
2215
  | `driver.start()` a **second time** | throws `MultipleRuns` — a driver serves one run and its id was fixed at construction (`ID-R3`) |
1379
- | `driver.resolve()` with a `result` that is not structured-cloneable | throws the platform's own `DataCloneError`, **not** `UncloneableResult`, and the run is not stopped |
2216
+ | `driver.resolve()` with a `result` that is not structured-cloneable | throws the platform's own `DataCloneError`, **not** `UncloneableResult`, and the run is not halted |
1380
2217
  | `driver.start()` with a payload that is not structured-cloneable | throws the platform's own `DataCloneError` with the driver UNSTARTED — a run whose entry payload cannot be copied into its checkpoint header cannot be checkpointed, so it is refused before it exists (`RN-R15`, [ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md)). **New in W88, and BREAKING**: such a payload used to start a run on an inline pool |
2218
+ | `driver.start()` or `runtime.start()` whose `onHeader` THROWS | throws YOUR error with the driver UNSTARTED — nothing recorded, nothing handed to the scheduler, `onRunFailure` not called: the run never existed, so it is not halted (`RN-R39`, [ADR 0112](doc/decisions/0112-a-run-delivers-its-header-before-its-first-event.md), section 18). A `Driver` may be started again; on a runtime the run id is SPENT (`RunIdInUse` after), as for any `start` that throws once its driver exists |
1381
2219
  | `driver.checkpoint()` before `start()` | throws `NotStarted` — there is no run, and no entry payload to put in a header (`RN-R14`) |
1382
2220
  | `driver.resume(checkpoint)` or `runtime.resume(flow, checkpoint)` with an entry payload that is not structured-cloneable | throws the platform's `DataCloneError` before anything is replayed — the driver unstarted, and on a runtime no run id spent (`RN-R15`) |
1383
- | `driver.checkpoint()` on a driver whose resume was refused | throws `NotStarted` — a refused resume leaves the driver SPENT and deaf to its scheduler, with no run to checkpoint (`RN-R23`) — **except** a resume refused `stopped` because its replay regenerated a stop in the lost tail: that driver keeps the STOPPED run, and its checkpoint is one a store may take. *(This row said `NotStarted` without the exception until W90 read it against `RN-R23`.)* |
1384
- | `driver.resume(checkpoint)` or `runtime.resume(flow, checkpoint)` on a checkpoint it will not resume | throws `ResumeRefused`, whose `reason` is `format`, `run`, `flow`, `stopped` or `diverged` and whose `index` names the position for the last two — nothing is handed to a scheduler ([ADR 0065](doc/decisions/0065-a-resume-replays-the-stored-stimuli-refuses-what-it-cannot-regenerate-and-re-dispatches-what-was-in-flight.md)). `resume()` on a driver that already started or resumed throws `MultipleRuns` (`ID-R3`). **On a runtime, a refusal DURING the replay spends the run id there** — a driver was built for it, so that runtime answers the id with `RunIdInUse` from then on (ADR 0091 keeps the id after it releases the driver); one refused by its header spends nothing ([Q371](doc/questions/q371.md), section 18) |
2221
+ | `driver.checkpoint()` on a driver whose resume was refused | throws `NotStarted` — a refused resume leaves the driver SPENT and deaf to its scheduler, with no run to checkpoint (`RN-R23`) — **except** a resume refused `halted` because its replay regenerated a stop in the lost tail: that driver keeps the HALTED run, and its checkpoint is one a store may take. *(This row said `NotStarted` without the exception until W90 read it against `RN-R23`.)* |
2222
+ | `driver.resume(checkpoint)` or `runtime.resume(flow, checkpoint)` on a checkpoint it will not resume | throws `ResumeRefused`, whose `reason` is `format`, `run`, `flow`, `halted`, `cancelled` or `diverged` and whose `index` names the position for the last three — nothing is handed to a scheduler ([ADR 0065](doc/decisions/0065-a-resume-replays-the-stored-stimuli-refuses-what-it-cannot-regenerate-and-re-dispatches-what-was-in-flight.md)). `resume()` on a driver that already started or resumed throws `MultipleRuns` (`ID-R3`). **On a runtime, a refusal DURING the replay spends the run id there** — a driver was built for it, so that runtime answers the id with `RunIdInUse` from then on (ADR 0091 keeps the id after it releases the driver); one refused by its header spends nothing ([Q371](doc/questions/q371.md), section 18) |
2223
+ | `driver.cancel(options)` the driver will not make | throws `RunControlRefused`, whose `reason` is `unsupported` (the scheduler has no `cancel`), `not-started`, `halted`, `cancelled`, `ended` or `interrupted` (the run was stopped) — nothing recorded and nothing sent to the scheduler (`RN-R31`, [ADR 0104](doc/decisions/0104-a-run-its-owner-cancels-is-cancelling-until-its-last-task-has-stopped.md), section 6); a `graceMs` that is not a number, or is negative or `NaN`, throws `TypeError` or `RangeError` the same way |
2224
+ | `driver.stop(options)` or `handle.stop(options)` the driver will not make | throws `RunControlRefused` with `control: 'stop'`, whose `reason` is `unsupported`, `not-started`, `halted`, `cancelled` (a cancelled run is not resumable) or `ended` — nothing recorded and nothing sent to the scheduler (`RN-R33`, [ADR 0105](doc/decisions/0105-a-run-its-owner-stops-goes-deaf-before-it-cancels-and-resumes-elsewhere.md), section 18); a bad `graceMs` throws `TypeError` or `RangeError` the same way |
2225
+ | `driver.pause(options)` or `driver.unpause(options)` the driver will not make | throws `RunControlRefused` with `control: 'pause'` or `'unpause'`, whose `reason` is `not-started`, `halted`, `cancelled`, `interrupted` (a stopped run — pause or unpause it where it resumes), `paused` (a second pause), `not-paused` (an unpause of a run not paused), or — a pause only — `ended`; nothing recorded (`RN-R35`, [ADR 0106](doc/decisions/0106-a-run-its-owner-pauses-routes-on-and-holds-every-dispatch-until-unpaused.md), section 8) |
1385
2226
 
1386
2227
  `MultipleRuns` is thrown by a second `start()`: a driver's sequence and join state carry no run id,
1387
2228
  so a second run would silently merge into the first. **One driver, one run** — and the run id is a
@@ -1426,7 +2267,85 @@ settlement it did not mint means its bookkeeping is wrong.
1426
2267
 
1427
2268
  **Reported, not thrown**, when there is nobody on the stack: `DuplicateTaskId`, `UncloneableResult`,
1428
2269
  `NoMatchingEdge` and `UndispatchedTask` raised *inside a scheduler event listener*. Give the driver
1429
- `onRunFailure` or you will not hear about them — the run stops and folds to `stopped`, silently.
2270
+ `onRunFailure` or you will not hear about them — the run stops and folds to `halted`, silently. **So is
2271
+ a throw from your own `onFoldInput` on any element a scheduler event's step appends** — including the
2272
+ `paused`, `unpaused` or `interrupted` element of a pause, unpause or stop you called from a callback on
2273
+ that step, which is taken when the step ends (sections 8 and 18): reported under your error's class and
2274
+ the run HALTED, the element already in the log (`OR-R10`). A stopped run halted so is not resumable.
2275
+ *Until 0.8.0's W178 that throw left the listener and ended the process.* **On YOUR call's step it is the
2276
+ SAME — and then it is thrown to you as well** (`OR-R10` v0.19, [W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md)): `start`, `resume`, `resolve`,
2277
+ `cancel`, `stop`, `pause` and `unpause`, called at rest, or with a pause, an unpause, a stop or an answer you
2278
+ called from a callback and that is taken when the step ends. Your error is reported ONCE under its class, the run
2279
+ is HALTED right after the element — the element's own side effects made first: a stop's and a cancel's cancels sent, an
2280
+ unpause's and a cancel's held tasks handed on — and THEN your error is thrown at you. **The FIRST throw halts**: a
2281
+ pause, an unpause or an answer you called on the same step and still pending behind it is DROPPED — nothing more is
2282
+ dispatched or routed — and a stop pending behind it is still taken, silently, its own throw discarded; so the error
2283
+ thrown at you is the one reported. So is a throw from the SCHEDULER once your call is accepted — its `cancel` failing
2284
+ inside `driver.cancel` or a stop: reported under its class, the run halted, the error thrown at you. A `cancel()` you call from a
2285
+ callback appends its element where you call it, so a throw on it halts the run there even if your callback catches
2286
+ it. A `resume()` whose `resumed` element you throw on hands NOTHING on (section 18); one whose lost tail you throw on
2287
+ is still refused `ResumeRefused('halted')`. **A call REFUSED before it is accepted halts nothing** —
2288
+ `RunControlRefused`, `BoundaryRefused`, `ResumeRefused`, `MultipleRuns`, a `DataCloneError`: nothing was appended. **Nor
2289
+ does a throw from your `onHeader`** (section 18): it runs inside `start` before anything is appended, so it is that
2290
+ call's refusal — thrown at you, nothing reported, the driver unstarted. **And a throw from your `onTaskMessage` or
2291
+ `onTaskProgress`** (section 8) is always a scheduler event's: reported once, the run halted, never thrown at you. That is
2292
+ the driver's guarantee, not only the timing: both reach your callback only through the driver's scheduler-event listener,
2293
+ which catches whatever it throws — and the scheduler delivers both on its own step anyway, never inside your call.
2294
+ **Two callbacks must not throw at all: `onFoldInput` on the `halted` element, and `onRunFailure`.** Each runs
2295
+ after the run is halted and the failure reported, so inside a scheduler event listener their throw is DISCARDED —
2296
+ lost, with nobody told (`OR-R10`); on your own call's step it reaches you.
2297
+
2298
+ **BREAKING in 0.8.0** (P36 (b), (c) and (p), [W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md)): until W184 a throw from your `onFoldInput` on your OWN
2299
+ call's step reached you and the run went ON. A `resolve()` whose `resolution` element you threw on left the answered
2300
+ node routed nowhere and the run folding **`completed`** — a run that never reached its end; a stop at rest so thrown
2301
+ stayed `interrupted` and resumable, a cancel `cancelling`, a pause or an unpause running; a `resume()` threw with its
2302
+ in-flight set never handed on, `running` for ever. Each is now **`halted`** — so a store that failed on a stop's
2303
+ `interrupted` element holds a run that is NOT resumable, and a runtime's `done` settles `halted` at once.
2304
+
2305
+ ```ts run
2306
+ import { Driver, fold, liveToken, type FlowDefinition, type FoldInput } from 'fyflow-orchestration';
2307
+
2308
+ const flow: FlowDefinition = {
2309
+ flowId: 'store-fails', version: 1, entry: 'x',
2310
+ nodes: [
2311
+ { id: 'x', kind: 'task', type: 'T', edges: [{ to: 'y' }] },
2312
+ { id: 'y', kind: 'task', type: 'T', edges: [{ to: 'done' }] },
2313
+ { id: 'done', kind: 'end', outcome: 'completed' },
2314
+ ],
2315
+ };
2316
+ // A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
2317
+ const added: string[] = [];
2318
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
2319
+ const scheduler = {
2320
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
2321
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
2322
+ };
2323
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
2324
+
2325
+ // YOUR store, which fails on ONE element: the resolution a person's answer appends.
2326
+ class StoreDown extends Error {}
2327
+ const reported: string[] = [];
2328
+ const driver = new Driver(flow, {
2329
+ runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken,
2330
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } },
2331
+ onFoldInput: (e: FoldInput) => { if (e.kind === 'resolution') throw new StoreDown('the store is down'); },
2332
+ onRunFailure: (f) => { reported.push(f.kind); },
2333
+ });
2334
+ driver.start({});
2335
+ // x runs out of retries and parks for a person: the scheduler's PAIR, `task.failed` then `task.user_action`.
2336
+ fire('task.failed', { id: 'r1:x:-:0', state: 'user_action', error: 'a person must look' });
2337
+ fire('task.user_action', { id: 'r1:x:-:0', state: 'user_action' });
2338
+
2339
+ // YOUR call: your error is thrown at you — AND the run is HALTED, and reported once.
2340
+ let thrown: unknown = null;
2341
+ try { driver.resolve('r1:x:-:0', 'done', { result: { ok: true }, actor: 'ana' }); } catch (e) { thrown = e; }
2342
+ const state = fold(driver.foldInputs());
2343
+ console.log(thrown instanceof StoreDown, reported, state.outcome, added); // true [ 'StoreDown' ] halted [ 'r1:x:-:0' ]
2344
+ if (!(thrown instanceof StoreDown)) throw new Error('your own error is thrown at you');
2345
+ if (reported.join() !== 'StoreDown') throw new Error(`reported ONCE, under its class: ${reported.join()}`);
2346
+ if (state.outcome !== 'halted') throw new Error(`the run is HALTED, got ${state.outcome} — before W184 it folded completed`);
2347
+ if (added.includes('r1:y:-:0')) throw new Error('nothing is routed from the resolution you threw on');
2348
+ ```
1430
2349
 
1431
2350
  **`InvalidTaskId` is the ninth class and it is the one that does NOT stop a run.** It is thrown by
1432
2351
  `buildTaskId` and `parseTaskId`, both exported, and it is how you check an id yourself — *did some
@@ -1460,7 +2379,7 @@ import { Driver, type RunFailure } from 'fyflow-orchestration';
1460
2379
  declare const opts: ConstructorParameters<typeof Driver>[1];
1461
2380
  const withReporting = {
1462
2381
  ...opts,
1463
- onRunFailure: (f: RunFailure) => console.error(`run ${f.runId} stopped: ${f.kind} — ${f.message}`),
2382
+ onRunFailure: (f: RunFailure) => console.error(`run ${f.runId} halted: ${f.kind} — ${f.message}`),
1464
2383
  };
1465
2384
  void withReporting;
1466
2385
  ```
@@ -1501,12 +2420,19 @@ named did not run on your path*, which is the **flow's** shape.
1501
2420
  `BoundaryRefused` is **W73's, and it is the one class here that STOPS NOTHING** — [P19](doc/plans/p19-what-a-schema-buys-an-author.md) decision 5's `FD-R4`. The two values no static check can reach are held to the workers' declared
1502
2421
  schemas ONCE each: the run's payload at `start()`, against the `inputSchema` of every task it reaches with no
1503
2422
  worker in between (the entry, or every branch task of an entry fan-out, or a map's branch once per item),
1504
- and a person's `done` resolution at `resolve()`, against the parked node's worker `outputSchema`. Refused, it is
2423
+ and a person's `done` resolution at `resolve()`, against the parked node's worker `outputSchema` — or, for an ASK,
2424
+ against the ask's own `answerSchema` (since [W180](doc/work-items/w180-asking-and-answering-in-a-run.md)). Refused, it is
1505
2425
  **thrown at you as a REJECTED COMMAND**: nothing appended, nothing reported, the run unaffected — at `start`
1506
2426
  the driver is still UNSTARTED and a corrected payload starts it, at `resolve` the task is still suspended and
1507
2427
  a corrected value (the worker's declared example is one) resolves it. It carries `boundary`, `nodeId`,
1508
2428
  `taskId` and the schema's `findings`, and its message names the worker and every finding, so you read
1509
- the VALUE and not the flow. A worker declaring no schema on that side is unchecked; `validateBoundaries: false`
2429
+ the VALUE and not the flow. A worker declaring no schema on that side is unchecked — **and so, at `start()`, is a
2430
+ task that declares a `transform`** ([ADR 0101](doc/decisions/0101-a-transforming-task-is-not-held-at-start.md), W170):
2431
+ its worker's `inputSchema` holds the transform's RESULT, which only the worker wrapper makes, so a payload that is
2432
+ wrong for the transform starts: it MAY fail in the run (`TransformFailed`, or the worker), or reach the worker
2433
+ unchecked and complete — Q633's `{ label: "y" }` ran to `completed` with the worker receiving `{}`. The check before
2434
+ a run is yours — `givenVerdict(registry, flow, task, received)` on each transforming target, with the value that task
2435
+ RECEIVES (section 15); `validateBoundaries: false`
1510
2436
  on `DriverOptions` or `RuntimeOptions` turns both off. **`FD-R4` is never per settlement**: it holds the run's PAYLOAD once and a person's RESOLUTION
1511
2437
  once, as rejected commands. What a WORKER returns is checked at authoring time by
1512
2438
  `npm run check:workers` and by the wiring check (section 13) — **and, since
@@ -1535,7 +2461,7 @@ import {
1535
2461
  BoundaryRefused,
1536
2462
  ConfigRefused, DerivationRefused, DuplicateTaskId, GateTypeRefused, GroupConfigRefused, GroupTypeMissing, InvalidDefinition,
1537
2463
  InvalidRunId, InvalidTaskId, MissingJoinPolicy, MultipleRuns, NameCollision, NameMissing, NoMatchingEdge, NotStarted,
1538
- PoolTypeMissing, RegistryError, ResumeRefused, SchemaRefused, TypeDefaultRefused, UncloneableResult, UndeclaredKey,
2464
+ PoolTypeMissing, RegistryError, ResumeRefused, RunControlRefused, SchemaRefused, TypeDefaultRefused, UncloneableResult, UndeclaredKey,
1539
2465
  UndeclaredRoute, UndispatchedTask, UnresolvedBranchPath, UnresolvedMapArity, UnresolvedReference, WorkerMissing,
1540
2466
  } from 'fyflow-orchestration';
1541
2467
 
@@ -1544,6 +2470,7 @@ const documented = [
1544
2470
  MultipleRuns, InvalidRunId, InvalidDefinition, MissingJoinPolicy, // thrown at you
1545
2471
  NotStarted, // thrown at you from checkpoint() before start() — there is no run yet (RN-R14)
1546
2472
  ResumeRefused, // thrown at you from resume() — a checkpoint it will not resume, `reason` says why (RN-R23)
2473
+ RunControlRefused, // thrown at you from driver.cancel(), stop(), pause() and unpause() — nothing recorded or sent, `reason` says why (RN-R31, RN-R33, RN-R35)
1547
2474
  RegistryError, WorkerMissing, PoolTypeMissing, ConfigRefused, TypeDefaultRefused, GroupTypeMissing, // thrown at you — a REGISTRY defect,
1548
2475
  GroupConfigRefused, NameCollision, SchemaRefused, UndeclaredKey, NameMissing, DerivationRefused, // the base class and its twelve (section 13)
1549
2476
  GateTypeRefused, // a registered GATE type that is not a GateType — checkRegistry (v0.23)
@@ -1565,8 +2492,12 @@ if (missing.length) throw new Error(`section 9 does not mention: ${missing.join(
1565
2492
  if (stale.length) throw new Error(`section 9 names classes the package no longer exports: ${stale.join(' ')}`);
1566
2493
  ```
1567
2494
 
1568
- After a refusal the run is **stopped**: nothing routes for it again, and its state folds to
1569
- `stopped` whatever its nodes say.
2495
+ After a refusal the run is **halted**: nothing routes for it again, and its state folds to
2496
+ `halted` whatever its nodes say. **BREAKING in 0.8.0** (P34 decision 10, ADR 0102): 0.7.0 called this `stopped`,
2497
+ and FOUR surfaces are renamed — the `RunOutcome` `stopped` is `halted`, `RunState.stoppedBy` is `haltedBy`, the fold
2498
+ input `{ kind: 'stopped', reason }` is `{ kind: 'halted', reason }`, and `ResumeRefusalReason`'s `stopped` is
2499
+ `halted` — so a comparison against an old name, or a `{ kind: 'stopped' }` you construct, no longer compiles. A 0.7.0 checkpoint or log holding a `{ kind: 'stopped' }` element still reads as `halted`:
2500
+ `fold` folds it so, and a resume refuses it `halted` at its position.
1570
2501
 
1571
2502
  ---
1572
2503
 
@@ -1581,12 +2512,18 @@ After a refusal the run is **stopped**: nothing routes for it again, and its sta
1581
2512
  flow is between every step. It is not "the run finished". `await done` — the second half of what
1582
2513
  `runtime.start` returns (section 8).
1583
2514
  - **Expecting `await done` to reject when a run goes wrong.** It resolves, always: `failed`, `stuck`
1584
- and `stopped` are outcomes you read off the state, and `stoppedBy` names a refusal. A `.catch` on
2515
+ and `halted` are outcomes you read off the state, and `haltedBy` names a refusal. A `.catch` on
1585
2516
  `done` is a `.catch` that never fires.
1586
2517
  - **Waiting on `done` for a run with a person in it.** `awaiting_action` is not terminal, so `done`
1587
2518
  is still pending — by design. Poll the fold for the park, resolve the task, then await; section 7
1588
2519
  is that snippet, and `Promise.race([done, timeout])` is the deadline nothing ships for you.
1589
2520
  - **Reading run state mid-turn.** Section 8.
2521
+ - **Counting on `start()` to check the payload of an entry that TRANSFORMS.** It does not — `FD-R4` holds a
2522
+ payload to a worker's `inputSchema` only where nothing stands between them, and a `transform` does
2523
+ ([ADR 0101](doc/decisions/0101-a-transforming-task-is-not-held-at-start.md)). *Check, then start*:
2524
+ `givenVerdict(registry, flow, task, received)` runs a task's declared transform and holds the result to the
2525
+ schema — on each transforming TARGET (never a fan-out, map or gate entry, where it returns `[]`) and on the value
2526
+ that task RECEIVES, the payload composed through its `needs` and, for a map's branch, each element — section 15.
1590
2527
  - **Retaining a scheduler event's `detail`.** It is the live task object and it mutates — including
1591
2528
  its `timestamp`, so a retained detail reports the *most recent* event's time. `{...e.detail}` is
1592
2529
  **not** compliance: it still aliases `result` and `error`. The driver copies at the listener
@@ -1644,8 +2581,9 @@ const frozen: Record<string, boolean> = {};
1644
2581
  const HANDLES = ['onFoldInput', 'onRegionEvent', 'snapshot', 'foldInputs'];
1645
2582
 
1646
2583
  // **`onRegionEvent` is FORWARDED, so this road can watch a fan-out open and close** — W76. It was
1647
- // not, until W76: `StartOptions` is a `Pick` of the driver's options, and of the driver's twenty
1648
- // (eighteen when W76 wrote this; `onNonconformingResult` is ADR 0060's, W80's; `gateTypes` ADR 0067's, W93's)
2584
+ // not, until W76: `StartOptions` is a `Pick` of the driver's options, and of the driver's twenty-three
2585
+ // (eighteen when W76 wrote this; `onNonconformingResult` is ADR 0060's, W80's; `gateTypes` ADR 0067's, W93's; `onHeader` ADR 0112's, W185's;
2586
+ // `onTaskMessage` and `onTaskProgress` ADR 0114's, W187's)
1649
2587
  // this was the one no `createRuntime` consumer could reach at all, which is
1650
2588
  // [Q297](doc/questions/q297.md). **The previous version of this snippet asserted the opposite and
1651
2589
  // passed a CAST**, because that was the only way to try; there is no cast now, and the two counts
@@ -1791,15 +2729,15 @@ if (failures.length !== 0) throw new Error(`the quiet half reported ${failures.j
1791
2729
  // `failures.length === 0` above is a measurement rather than a sentence about an empty array.
1792
2730
  //
1793
2731
  // **And `done` RESOLVES here rather than rejecting** (ADR 0058 rules 3 and 4): a refusal is a
1794
- // terminal OUTCOME, not an error, so there is no `.catch` — the run arrives as `stopped` with
1795
- // `stoppedBy` naming the refusal. One settlement rule for every way a run can end.
1796
- const stoppedRun = await run(loud);
2732
+ // terminal OUTCOME, not an error, so there is no `.catch` — the run arrives as `halted` with
2733
+ // `haltedBy` naming the refusal. One settlement rule for every way a run can end.
2734
+ const haltedRun = await run(loud);
1797
2735
  await runtime.shutdown();
1798
- console.log(failures.join(), stoppedRun.outcome, stoppedRun.stoppedBy?.split(':')[0]);
1799
- // 'UncloneableResult' 'stopped' 'UncloneableResult'
2736
+ console.log(failures.join(), haltedRun.outcome, haltedRun.haltedBy?.split(':')[0]);
2737
+ // 'UncloneableResult' 'halted' 'UncloneableResult'
1800
2738
  if (failures.join() !== 'UncloneableResult') throw new Error(`the loud half reported ${failures.join() || 'nothing'}`);
1801
- if (stoppedRun.outcome !== 'stopped') throw new Error(`a refused run resolved \`done\` with ${stoppedRun.outcome}, not stopped`);
1802
- if (!stoppedRun.stoppedBy?.startsWith('UncloneableResult')) throw new Error(`stoppedBy: ${stoppedRun.stoppedBy}`);
2739
+ if (haltedRun.outcome !== 'halted') throw new Error(`a refused run resolved \`done\` with ${haltedRun.outcome}, not halted`);
2740
+ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`haltedBy: ${haltedRun.haltedBy}`);
1803
2741
  ```
1804
2742
 
1805
2743
  ## 11. What does NOT exist — do not write code using it
@@ -1807,10 +2745,18 @@ if (!stoppedRun.stoppedBy?.startsWith('UncloneableResult')) throw new Error(`sto
1807
2745
  | not available | instead |
1808
2746
  |---|---|
1809
2747
  | flow-level **retry** — re-dispatching a NODE under a new attempt, possibly routing elsewhere | **task-level retry IS built** since [W58](doc/work-items/w58-what-a-node-says-about-its-own-task.md): declare `retryPolicy` on the node (section 3) and the scheduler retries inside the same task id, reported on the record's `attempts`. Re-dispatching the node is [Q16](doc/questions/q16.md)'s other half and the task id's fourth segment, and nothing mints one |
1810
- | **cancellation** | nothing. The scheduler cannot cancel a queued task either |
1811
- | **durability** — resuming a run after a restart | nothing. The fold is designed to make it additive; it is not built |
2748
+ | **`runtime.register()`** — adding node types, pools, groups or languages to a RUNNING runtime | **declined** — [P37](doc/plans/p37-a-runtime-is-its-registry.md) decision 1, [ADR 0096](doc/decisions/0096-a-runtime-is-its-registry-and-start-refuses-from-runtime-check.md): `fyflow-scheduler` cannot add a pool after construction. A changed registry is a NEW runtime; a run on the old one finishes there or moves by stop-and-resume ([P34](doc/plans/p34-what-cancelling-stopping-and-pausing-a-run-do.md)). **A FLOW is not a registry entry**: `runtime.check(flow)`, then `runtime.start(flow, …)`, takes any flow the registry can run, at any time (section 12) |
2749
+ | **a `limitKey` from the FLOW** — a node or a document saying which key a task of a keyed group is counted under | **deferred** — [Q248](doc/questions/q248.md). Pass `start` a `createTask` that sets `limitKey` on the task it builds, or register a group type whose `create` builds a `KeyedRateLimitGroup` with a `keyFrom` (section 12). A node reaching a document's built-in keyed group with neither is refused before its run starts — `FD-R6`, `LimitKeyMissing` |
2750
+ | **a `start()` that checks a TRANSFORMING task's payload** — the transform run first and its result held to the `inputSchema` | **no** — [ADR 0101](doc/decisions/0101-a-transforming-task-is-not-held-at-start.md): `start()` is synchronous and the transform runs only in the worker, so a task declaring one is UNCHECKED at `start` (`FD-R4`), as a worker declaring no `inputSchema` is. Call `givenVerdict(registry, flow, task, received)` — `tryTransform(…, { input: received })` on the declared transform — first, on each transforming target with the value it RECEIVES (section 15), then start |
2751
+ | **redaction by `writeOnly`** — a schema annotation keeping a value out of what a run retains or reports | **no** — [ADR 0098](doc/decisions/0098-four-annotations-join-the-schema-subset-and-constrain-nothing.md) rule 4: `writeOnly`, like `readOnly`, `title` and `description`, is admitted as an annotation since v0.36, held to its shape and read by nothing after. Redact in your own code, before the value reaches a run (section 13) |
2752
+ | **pool options on a CLASS type's instance**, and the scheduler's `idleCheckIntervalMs`, `requeueFailedTasks` and `maxWorkerRestarts` anywhere | **no** — [ADR 0099](doc/decisions/0099-a-named-instance-of-a-document-type-may-declare-its-own-pool-options.md) rules 4 and 7: a named instance of a DOCUMENT type may declare `pool` (section 13); a class computes its pool from the instance's `config` and key, so give it a `configSchema` knob. The three are refused as undeclared keys |
2753
+ | ~~**cancelling a RUN**~~ | **BUILT** since [W173](doc/work-items/w173-cancelling-a-run.md): `driver.cancel({ actor, reason?, graceMs? })` — section 6; the run reads `cancelling` and `done` settles `cancelled` after its last task has stopped. **Not cancelled with it: tasks a WORKER spawned** (`context.spawnTask()`) — the driver knows only what it dispatched, so track their ids from `task.spawn_request` and cancel each (P34 (j)). **And `graceMs: 0` is a request, not a guarantee** — a 0 ms timer a prompt worker can beat ([S12](doc/scheduler-upstream.md)) |
2754
+ | ~~**stopping a run**~~ to resume it elsewhere | **BUILT** since [W174](doc/work-items/w174-stopping-a-run-to-resume-it-elsewhere.md): `handle.stop({ actor, reason?, graceMs? })` or `driver.stop(…)` — section 18; the run reads `interrupted`, its `done` settles once the stop's cancels have resolved, and its checkpoint resumes on a FRESH runtime (this one keeps the run id). **Not stopped with it: tasks a WORKER spawned**, as for a cancel |
2755
+ | ~~**pausing** a run~~ | **BUILT** since [W175](doc/work-items/w175-pausing-a-run.md): `driver.pause({ actor })` / `driver.unpause({ actor })` — section 8; the run routes on, every dispatch is recorded and HELD, and the unpause hands them on in dispatch order. `state.paused` is a flag, not an outcome. **Nothing is cancelled**: a task the scheduler already has — queued behind a group included — runs on. There is no `handle.pause()`: call `handle.driver.pause()` |
2756
+ | ~~**durability**~~ — resuming a run after a restart | **BUILT** since [W89](doc/work-items/w89-what-resuming-is.md): append the events, resume from them — section 18. A cancelled or halted run is refused |
1812
2757
  | **nested fan-out** — a branch that fans out again | **built, and the join over it is settled.** `FD-V15` is retired; a task inside a nested region is dispatched at its whole dotted `branchPath` (`r1:scoreC:1.0:0`); and every policy in section 5 is **transitive** — each region answers exactly as it would if it were not nested ([ADR 0030](doc/decisions/0030-a-join-is-transitive-and-a-dead-join-ends-the-branch-it-sits-in.md)). One thing to know: if an inner join is reported through `onUnsatisfiableJoin`, the outer branch containing it **ends** rather than waiting, so the outer join may still fire and its successor may run — and once that work settles the run folds to `stuck`, because a region that never fired is what `stuck` reads. **Fold too early and you will see `running`.** The four matrix rows and the join seam are tested at **depth 2**; the dead-join cascade is asserted at depth 3, and nothing else about depth 3 is a tested claim |
1813
2758
  | ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
2759
+ | ~~**RUNNING an `ask`**~~ — a node that waits for a person, its answer at `resolve()` | **BUILT** since [W180](doc/work-items/w180-asking-and-answering-in-a-run.md): a run parks at an ask with nothing handed to the scheduler, and its answer is its result — section 7. Under a cancel, a stop, a pause and a resume an ask is a parked task — never in flight, so nothing is ever sent or handed on for it — since [W181](doc/work-items/w181-an-ask-under-run-control.md) (section 7) |
1814
2760
  | a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
1815
2761
  | **per-join policy choice** | one policy per driver |
1816
2762
  | a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
@@ -1823,6 +2769,14 @@ so you can fold incrementally and see a run finish without polling; `onRegionEve
1823
2769
  opening, arriving, exiting, suspending and resuming; `joinStatusOf()` answers what a join is waiting
1824
2770
  on. What is missing is an agreed envelope shape between the two repositories, not the data.
1825
2771
 
2772
+ **Runtime compatibility, as this package intends it: Deno, Node, and the browser for the library but
2773
+ not for a flow** — the browser rung is [Q52](doc/questions/q52.md), and a worker there must be a
2774
+ bundled `.js` asset. **It is a jsr.io package SETTING, not a manifest field**: measured at W157, Deno's
2775
+ `deno.json` schema has no `runtimeCompat` key, `deno publish --dry-run` accepts one and nothing reads
2776
+ it, and JSR's API carries it per package. Every lane of the gate runs Node 24; **one L6 run on Node
2777
+ 26.11.1** — the version fyflow-app's host targets — exited 0 with every assertion passing at W157, and
2778
+ is recorded rather than added to the gate.
2779
+
1826
2780
  ---
1827
2781
 
1828
2782
  ## 12. Wiring it to a real scheduler
@@ -1832,7 +2786,8 @@ on. What is missing is an agreed envelope shape between the two repositories, no
1832
2786
  name the document uses against the code you supply, refuses every defect at construction by a named
1833
2787
  error, builds one pool per type instance and one object per group, holds a `FyflowScheduler` over
1834
2788
  them, and hands you **`{ driver, done, header }` per run** — the driver already running, `done` a
1835
- promise that resolves with the run's terminal `RunState`, and `header` the run's checkpoint header
2789
+ promise that resolves with the run's terminal `RunState`, and `header` the run's checkpoint header — handed to
2790
+ `start`'s `onHeader` option too, before the run's first event, for a store that writes it first (section 18)
1836
2791
  ([ADR 0058](doc/decisions/0058-runtime-start-returns-a-completion-handle-the-runtime-keeps.md);
1837
2792
  section 8 is what it waits for). **What stays yours is exactly what is code**: predicates,
1838
2793
  language adapters, custom group types and custom node-type classes, in one `code` argument.
@@ -1983,11 +2938,38 @@ with no `onStart` throws on its first task and one with no `onFinish` leaves eve
1983
2938
  running for ever, while one with no `getMetrics` runs fine —
1984
2939
  [ADR 0055](doc/decisions/0055-a-group-factorys-output-is-held-to-what-the-scheduler-calls.md).)*
1985
2940
 
1986
- **If a flow transforms, tell the runtime about it.** A language adapter's worker half is written
1987
- into a pool's config at construction, and only for the types whose nodes transform (section 13), so
1988
- pass every flow the runtime will run: `createRuntime(registry, code, { base, flows: [flow] })`. A
1989
- flow handed to `start` that transforms on a type no pool was built for is refused there, naming the
1990
- type and the language, rather than failing every task of that node one run in.
2941
+ **A runtime is its registry, and a flow needs nothing passed in advance** —
2942
+ [ADR 0096](doc/decisions/0096-a-runtime-is-its-registry-and-start-refuses-from-runtime-check.md). Node
2943
+ types, pools, groups and languages are fixed at `createRuntime`, and **every typed pool carries every
2944
+ language the registry document names** — the built-in `Transform` and every named instance included —
2945
+ so a flow published after the runtime started runs on it, transforming on any type, with nothing extra.
2946
+ **`runtime.check(flow)` returns every finding `start` would refuse that flow on** — an array of
2947
+ `{ rule, at, detail }`, empty when there is none, and it never throws for a flow's defect — and
2948
+ **`start` and `resume` refuse from that same call**, throwing `InvalidDefinition` with exactly those
2949
+ rules — every one but `FD-R6`, below — before any run id is spent, so the two cannot disagree: *register* a flow by checking it, then
2950
+ start it. `check` leaves out what depends on a RUN rather than the flow — a payload `FD-R4` refuses,
2951
+ and a run id in use. **One finding it returns is not a definition defect: `FD-R6`**
2952
+ ([ADR 0097](doc/decisions/0097-what-start-refuses-that-the-flow-does-not-decide.md)) — a node reaching a group of the BUILT-IN `KeyedRateLimitGroup` type, through the
2953
+ pool it runs on — the named instance its `pool` says, else its type's default — or its `workerGroups`. The runtime builds that group with no `keyFrom`, so the scheduler counts each task
2954
+ under its `limitKey` — and nothing sets one unless you pass `start` a `createTask` that does, `(c) => new FyflowTask({
2955
+ ...c, limitKey: tenant })`; without one the scheduler's `addTask` throws. So `start` with no `createTask` throws
2956
+ **`LimitKeyMissing`** for it — after any `InvalidDefinition`, before any run id is spent — and with one, `FD-R6` refuses
2957
+ nothing (a factory that sets no key meets the scheduler's own throw). `check` cannot know what you will pass, so it
2958
+ reports `FD-R6` as if you pass none: filter it out by `f.rule` if you start with a keying `createTask`. A group a CODE
2959
+ group type builds — a `KeyedRateLimitGroup` with a `keyFrom` — is never `FD-R6`. A pool's `residentGroups` is not read
2960
+ here: a built-in keyed group held RESIDENT is refused earlier, at `createRuntime` (`DerivationRefused`, section 13,
2961
+ [Q619](doc/questions/q619.md)). A key derived from the flow is
2962
+ [Q248](doc/questions/q248.md), deferred. **And `createRuntime` refuses a malformed `defaultRetryPolicy`** —
2963
+ `InvalidDefinition`, `FD-V30`, at construction, where until W163 every `start` threw it. **BREAKING in 0.8.0**: `createRuntime`'s and `bootstrap`'s `flows` option,
2964
+ `LanguageNotBuilt` and `transformLanguagesByType` are gone. A caller passing `flows` in an object
2965
+ LITERAL fails to compile; one passing an options object held in a variable still compiles, and `flows`
2966
+ is then an unread key at run time — so delete it either way, and any warm-up flows written to feed it. **The cost** is a thread's, not a
2967
+ bundle's: each worker of a typed pool loads every registered engine in its `setup()` — measured at
2968
+ W162, about 22 ms and 6.4 MiB of heap per node thread for JSONata, and nothing measurable inline,
2969
+ where registering the language already loaded it. A registry naming no language pays nothing. **A
2970
+ changed registry is a NEW runtime** (`runtime.register` is declined, section 11): while two overlap,
2971
+ their pools and resource groups are SEPARATE, so a rate limit may briefly be exceeded and a model a
2972
+ worker loads in `setup()` is loaded twice.
1991
2973
 
1992
2974
  **Thousands of runs at once: start them all on ONE runtime.** Every run `start` (or `resume`) opens
1993
2975
  shares the runtime's pools and its one scheduler, and the runtime ROUTES each settlement to the driver
@@ -2003,7 +2985,8 @@ not routed, and both are yours to size:
2003
2985
  that are not its run's (`ID-R4`). That is correct, and N of them on one scheduler is N² deliveries.
2004
2986
  For a handful of runs it does not matter; for thousands, go through `start`.
2005
2987
  - **A listener of your own** on `runtime.scheduler` — a dashboard, a logger — hears every event of
2006
- every run. Key it on `detail.id` and do O(1) work per event, or N such listeners are N² again.
2988
+ every run. Key it on `detail.id` — or `detail.taskId` for `task.message`, which has no `id` — and do O(1) work per
2989
+ event, or N such listeners are N² again.
2007
2990
 
2008
2991
  A task id that does not parse — work this library did not mint — still reaches EVERY live run's
2009
2992
  driver, which each report it through `onUnmintedTask` (section 9): nobody owns it, so everybody says so.
@@ -2016,7 +2999,7 @@ parked `awaiting_action` is kept — `resolve()` works as before. **The runtime
2016
2999
  past run, so a postmortem is what YOU keep**: the `RunHandle` (`driver.foldInputs()`, `snapshot()`,
2017
3000
  `checkpoint()`, the `RunState` `done` resolved with), or what your `onFoldInput` stored. Drop the
2018
3001
  handle and the run is gone. After `done`, a released run is handed nothing — not its own run id, not
2019
- an unminted one — so a `stopped` run's tasks still in flight settle into no driver; listen on
3002
+ an unminted one — so a `halted` run's tasks still in flight settle into no driver; listen on
2020
3003
  `runtime.scheduler` yourself if you want their results. **The id stays in use**: `start` or `resume`
2021
3004
  naming it on the same runtime throws `RunIdInUse`, because those late settlements carry it. And the
2022
3005
  SCHEDULER keeps every completed task in `scheduler.tasks` unless `maxCompletedTasks` caps it (its own
@@ -2104,7 +3087,11 @@ pools from, as above, rather than a second list; every task the driver dispatche
2104
3087
  envelope `{ settings, input }` (section 13), which is what this repository's worker reads `input`
2105
3088
  from. Pass `createTask` only to run against a
2106
3089
  double — ladder L2-L5 here hand the driver `(c) => c` and read the plain object back — or to build
2107
- something other than a plain `FyflowTask`.
3090
+ something other than a plain `FyflowTask`. **A `createTask` that builds the task FIELD BY FIELD must copy
3091
+ `attemptsSpent`** (since 0.8.0): a resume hands a task it runs again the attempts it already made, as that key, only
3092
+ when there were some — and a factory that drops it starts the task at a fresh retry budget again, silently (section
3093
+ 18). `(c) => new FyflowTask({ ...c, limitKey })` copies it; `new FyflowTask({ id: c.id, workerType: c.workerType, … })`
3094
+ does not.
2108
3095
 
2109
3096
  **The `scheduler` option takes a `FyflowScheduler` as it is.** Until W57 it did not, under the one
2110
3097
  configuration most consumers have: `FyflowScheduler extends EventTarget`, so with the DOM lib in
@@ -2114,6 +3101,13 @@ was [Q71](doc/questions/q71.md), a defect in our type and not in yours, and it i
2114
3101
  `SchedulerLike`'s listener parameter is `object`, which is what makes the real class satisfy it,
2115
3102
  and every snippet in this document is typechecked under the DOM lib by `npm run check:docs`.
2116
3103
 
3104
+ **`SchedulerLike` has a THIRD member since 0.8.0, and it is OPTIONAL: `cancel(taskId, options?)`** — the
3105
+ scheduler's own signature, which a `FyflowScheduler` has and a runtime's per-run view forwards
3106
+ ([ADR 0104](doc/decisions/0104-a-run-its-owner-cancels-is-cancelling-until-its-last-task-has-stopped.md)). It is
3107
+ read by `driver.cancel` and `driver.stop` alone (sections 6 and 18), so a double with only `addTask` and
3108
+ `addEventListener` still drives every run — and either over it is refused `unsupported`, by name, before anything is
3109
+ recorded. Give your double a `cancel` that returns a promise if it must cancel or stop a run.
3110
+
2117
3111
  **A worker file can be TypeScript.** Node 24 runs it by stripping types, and Deno natively, so **one
2118
3112
  `.ts` worker serves Node and Deno**. *(The scheduler's own reference **used to say** a Node worker
2119
3113
  must be JavaScript, which was stale at Node 24 — [S9](doc/scheduler-upstream.md), raised 2026-09-07
@@ -2124,20 +3118,30 @@ called the reference stale for nine days and then for one more, in the document
2124
3118
  a browser build needs the worker emitted as a bundled `.js` asset. `RT-R10` — ladder L6 runs in Node
2125
3119
  and Deno and not in the browser, and the browser rung is [Q52](doc/questions/q52.md).
2126
3120
 
2127
- **Pin the scheduler at 0.5.2.** This library's listener boundary is written against **0.3.0
2128
- semantics, which neither 0.5.1 nor 0.5.2 changes**: `task.progress`'s detail changed shape at 0.3.0 (the id is
2129
- `id`, not `taskId`), the second `task.user_action` dispatch site arrived there, and `task.timestamp`
2130
- did not exist before it. **`core/FyflowScheduler.ts` is byte-identical between 0.3.0, 0.5.1 and 0.5.2**, so
2131
- every one of those still holds; what 0.5.1 added is a `worker.setup.*` / `worker.teardown.*` event
2132
- family this library subscribes to not at all. Install it from a registry — npm and JSR both carry it —
2133
- and **never from a working tree**, which is
3121
+ **Pin the scheduler at exactly the version this package's `peerDependencies` names** — read it in
3122
+ this package's `package.json`, or run `npm ls fyflow-scheduler` once it is installed. *(Until W157 this sentence carried the
3123
+ number, and so did four other documents; each bump was five edits that agreed only by care. The
3124
+ number now lives in the manifests and the lock files, and `npm run check:scheduler`'s `D7` refuses it
3125
+ anywhere else.)* This library's listener boundary is written against **0.3.0 semantics**:
3126
+ `task.progress`'s detail changed shape at 0.3.0 (the id is `id`, not `taskId`), the second
3127
+ `task.user_action` dispatch site arrived there, and `task.timestamp` did not exist before it.
3128
+ **`core/FyflowScheduler.ts` was byte-identical between 0.3.0, 0.5.1 and 0.5.2**; it is not at the
3129
+ current pin, and W157 re-read what this library rests on there rather than inferring it: the
3130
+ `user_action` pair is unchanged on both of its paths, and `task.progress` is built from the same
3131
+ fields. What 0.5.1 added is a `worker.setup.*` / `worker.teardown.*` event family this library
3132
+ subscribes to not at all. **The current pin adds a sixth task state, `cancelled`, reached only through
3133
+ `scheduler.cancel(id)`**, and a run hears it: a cancelled task ends its run `cancelled` and settles
3134
+ its `done` (section 6, [P33](doc/plans/p33-what-a-run-does-when-a-task-ends-cancelled.md)) — on real
3135
+ workers in ladder L6, a task cancelled while running and while queued — and `driver.cancel` asks for one on
3136
+ every task of a run in flight (section 6). Install the
3137
+ scheduler from a registry — npm and JSR both carry it — and **never from a working tree**, which is
2134
3138
  [ADR 0029](doc/decisions/0029-the-scheduler-is-a-registry-dependency-and-nothing-reaches-the-working-tree.md)
2135
3139
  superseding [ADR 0013](doc/decisions/0013-the-scheduler-is-a-development-dependency-reached-from-the-working-tree.md)
2136
3140
  rules 3 and 5. **The manifests say the same thing**, since
2137
3141
  [ADR 0048](doc/decisions/0048-the-scheduler-is-a-declared-dependency-reached-by-one-bare-specifier.md):
2138
3142
  `package.json` names it in `peerDependencies` at exactly that version and `deno.json` maps it to
2139
3143
  `jsr:@fyflow/scheduler` at exactly that version, and `npm run check:scheduler`'s `D8` holds both to
2140
- the pin the same way `D7` holds this sentence. *(This read **"pin at 0.3.0 … npm and JSR both stop at 0.2.1, so 0.3.0 is reached from
3144
+ the pin. *(This read **"pin at 0.3.0 … npm and JSR both stop at 0.2.1, so 0.3.0 is reached from
2141
3145
  a working tree"** — a live instruction to a consumer, wrong in the version AND in the mechanism, the
2142
3146
  second having been superseded on 2026-08-31. W36's conformity reader.)*
2143
3147
 
@@ -2175,9 +3179,11 @@ const definition: FlowDefinition = {
2175
3179
  // A fake scheduler, so this snippet runs with no workers. In your program this is
2176
3180
  // `new FyflowScheduler({ ... })` — see section 12.
2177
3181
  //
2178
- // **Key the listeners by event type.** The driver subscribes to three of them and
2179
- // normalizes on the task's terminal STATE rather than the event name (`OR-R3`), so a
2180
- // double that fans one settlement to every listener writes THREE records for it and
3182
+ // **Key the listeners by event type.** The driver subscribes to five of them — the four
3183
+ // settlements and, since 0.8.0, `task.running` (and `task.message` and `task.progress` when you pass
3184
+ // `onTaskMessage` or `onTaskProgress`, section 8) — and normalizes a settlement on the task's
3185
+ // terminal STATE rather than the event name (`OR-R3`), so a
3186
+ // double that fans one settlement to every listener writes FOUR records for it and
2181
3187
  // the run stops on a `DuplicateTaskId`. A real `EventTarget` does not do this; a
2182
3188
  // hand-written double is where it happens.
2183
3189
  const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
@@ -2315,7 +3321,7 @@ import { validateDefinition, type FlowDefinition, type NodeTypeDeclaration, type
2315
3321
  // key refuses absence by name, and a type whose nodes carry no settings is written over an entry
2316
3322
  // that declares none (two entries here, one module each — since W67 a worker is a module). `configSchema` is held against every pool's
2317
3323
  // derived config at construction; `inputSchema` and `outputSchema` are declared and carried, enforced on ONE
2318
- // value per run each (the entry payload at start(), a person's resolution at resolve() — FD-R4, W73)
3324
+ // value per run each (the entry payload at start() unless the task transforms, a person's resolution at resolve() — FD-R4, W73)
2319
3325
  // and never per settlement — since v0.20 (ADR 0056) any schema may carry `examples`, held to it at
2320
3326
  // construction, `npm run check:workers` runs a worker on its input example (P19, W72), and since v0.21
2321
3327
  // `checkWiring(registry, flow)` gives every arc of a flow a verdict from the two (W73).
@@ -2403,10 +3409,10 @@ declare const typed: FlowDefinition;
2403
3409
  declare const languages: Languages;
2404
3410
 
2405
3411
  // The types' groups are given as OBJECTS here and only here; the helper returns them keyed as given.
2406
- // The languages and the flows are what tells the helper WHICH typed pools need which adapter's
2407
- // worker module — written into a pool's config only where a node of that type transforms (W61).
3412
+ // The languages' worker modules are written into EVERY pool's config, whatever a flow transforms on
3413
+ // (ADR 0096) — the helper reads no flow.
2408
3414
  const registry = { workers: { ingest, scorer }, nodeTypes: { IngestWorker, Scorer } };
2409
- const built = bootstrap(registry, { scorer: new ConcurrentLimitGroup(4, 'scorer') }, { languages, flows: [typed] });
3415
+ const built = bootstrap(registry, { scorer: new ConcurrentLimitGroup(4, 'scorer') }, { languages });
2410
3416
  const scheduler = new FyflowScheduler(built.workerPools, built.resourceGroups);
2411
3417
  const driver = new Driver(typed, {
2412
3418
  runId: 'run-1', scheduler, predicates: new Map(), joinPolicy: liveToken,
@@ -2426,6 +3432,49 @@ spent per admission and not per task. Both are held at
2426
3432
  construction: a type's policy to `FD-V30` and a type's groups to the registry's `groups` under
2427
3433
  `FD-V31`, naming the type.
2428
3434
 
3435
+ **What a type's `pool` may declare, and what its worker is handed beside the envelope** —
3436
+ `spec/flow-definition.md` v0.33, [W159](doc/work-items/w159-what-the-pin-adds-that-a-type-reaches.md).
3437
+ `pool` takes the scheduler's `maxThreads`, `maxConcurrentTasks`, `idleTimeout` and `residentGroups`, and
3438
+ since v0.33 **`cancelGraceMs`** — on a THREADED pool, the ms a cancelled task's worker gets to stop by
3439
+ itself before the scheduler forces it (`Infinity` never forces; an inline pool ignores it) — and
3440
+ **`messages: { flushIntervalMs?, maxBuffered? }`**, the bounds on a worker's `emit()`. Each is held to the
3441
+ scheduler's own rule at construction, `DerivationRefused` naming the type and the key, so a value the
3442
+ scheduler would throw on at `new WorkerManager` is refused where the registry is built. Three more are
3443
+ refused here where the scheduler says nothing: a `messages` that is not a PLAIN object (a Map, a Date, an
3444
+ array or a class instance — the scheduler spreads it into its defaults and reads nothing), a
3445
+ `cancelGraceMs` of `null` (the scheduler takes it for absent and uses 2000), and a stray key inside
3446
+ `messages` — which the scheduler does not merely ignore: it carries the object to every worker, so on a
3447
+ threaded pool a stray value that cannot be cloned fails the task at run time with *could not be cloned*.
3448
+ **`flushIntervalMs` is capped at 2^31-1 ms** (v0.37), the longest delay a timer holds: the scheduler arms `setTimeout`
3449
+ with it, and a longer one fires at once — so a value meaning *batch for a month* would flush every `emit()` after 1 ms.
3450
+ **And a NAMED instance of a document type may declare its own** — `pools: { "scorer-1": { "type": "Scorer", "pool":
3451
+ { "maxThreads": 1 } } }`, `spec/flow-definition.md` v0.37, [ADR 0099](doc/decisions/0099-a-named-instance-of-a-document-type-may-declare-its-own-pool-options.md),
3452
+ [W165](doc/work-items/w165-pool-options-on-a-named-pool-instance.md): the same keys, each held to the same rule and
3453
+ refused naming the pool, **merged SHALLOWLY over the type's** — the instance's key wins, a key it leaves out (or sets
3454
+ to `undefined`) is the type's, and a `messages` it declares replaces the type's WHOLE — with every object copied, so
3455
+ no two pools share one. Two instances of one type need no second type. **An instance of a CLASS type carrying `pool` is
3456
+ refused** (`UndeclaredKey`): your class gets the instance key in `pool(config, key)`, so the knob belongs in its
3457
+ `configSchema`. The scheduler's `idleCheckIntervalMs`, `requeueFailedTasks` and `maxWorkerRestarts` are refused on a
3458
+ type and on an instance alike. **A RESOLVED document type is still a document type here** — what
3459
+ `runtime.registry.nodeTypes` holds — so a `Driver` or `bootstrap` handed the runtime's registry accepts the instance;
3460
+ a hand-written `NodeTypeDefinition`, or a copy of a resolved one, is a class to this rule.
3461
+ **A `residentGroups` naming a group of the built-in `KeyedRateLimitGroup` type is refused at `createRuntime`** (v0.39,
3462
+ [Q619](doc/questions/q619.md), W169) — `DerivationRefused`, naming the pool and the group, whether a type, a named
3463
+ instance or your class's `pool()` put it there. The scheduler resolves no key for a RESIDENT group, so every worker
3464
+ holding it would be counted in ONE `""` bucket, in silence. Name a keyed group in `groups` instead — each task is then
3465
+ counted under its `limitKey` (`FD-R6`, section 12) — or hold a `ConcurrentLimitGroup` or `RateLimitGroup` resident. A
3466
+ group your own `code.groupTypes` factory builds is never refused. **BREAKING in 0.8.0**: such a document built a
3467
+ runtime at 0.7.0. **`bootstrap` called by hand is not covered** — it is handed group OBJECTS and cannot tell the
3468
+ built-in factory's keyed group from your own — as a `Driver` built by hand is not covered by `FD-R6`.
3469
+ **Your worker's `run(payload, context)` gets the scheduler's own task context as its second argument** —
3470
+ the same object the scheduler handed the envelope worker, so `context.signal` (an `AbortSignal`, aborted
3471
+ when `scheduler.cancel(taskId)` is called for the task) and `context.emit(name, data)` (a `task.message`
3472
+ on the scheduler) work in a typed worker exactly as in one the scheduler loads itself, and its
3473
+ constructor gets the worker context. The library reads the signal never, and a `task.message` only when you ask:
3474
+ pass `onTaskMessage` to `start` (since 0.8.0, section 8) and each message of the run's own tasks reaches you with
3475
+ its run, node, branch and attempts as fields — or listen for it on `runtime.scheduler` yourself, as before; what
3476
+ cancelling a task does to its RUN is section 6's.
3477
+
2429
3478
  **A transform — what a node may DO to its input before its worker sees it** — `spec/flow-definition.md`
2430
3479
  v0.18, [ADR 0051](doc/decisions/0051-a-transform-is-a-declared-expression-in-a-registered-language-applied-in-the-types-worker-wrapper.md),
2431
3480
  [W61](doc/work-items/w61-what-a-transform-is.md). When the value a typed node receives is not the
@@ -2451,7 +3500,7 @@ produced. The language this package ships is JSONata, as the sub-path `fyflow-or
2451
3500
  — section 14; the snippet below uses a toy one so it loads no engine, and it is the same interface.
2452
3501
 
2453
3502
  ```ts run
2454
- import { validateDefinition, type FlowDefinition, type LanguageAdapter, type NodeTypeDeclaration, type Registry, type WorkerEntry } from 'fyflow-orchestration';
3503
+ import { DerivationRefused, findings, resolveRegistry, SchemaRefused, UndeclaredKey, validateDefinition, type FlowDefinition, type LanguageAdapter, type NodeTypeDeclaration, type Registry, type WorkerEntry } from 'fyflow-orchestration';
2455
3504
  import { EnvelopeWorker, TransformFailed } from 'fyflow-orchestration/scheduler';
2456
3505
 
2457
3506
  // A toy language: an expression is a dotted key path into the input, or `$settings.<path>`.
@@ -2499,8 +3548,8 @@ if (!rules(score({ lang: 'path' })).includes('FD-V37')) throw new Error('FD-V37:
2499
3548
  if (!rules(score({ lang: 'path', expr: 'confidence' }, {})).includes('FD-V23')) throw new Error('FD-V23: a transform on a node with no type is a node with no type');
2500
3549
 
2501
3550
  // And what the wrapper does with it — the class the bootstrap helper puts around your worker,
2502
- // driven here directly. The helper writes `languages` into a typed pool's config for you when you
2503
- // pass it the flows and the languages: `bootstrap(registry, groups, { languages: { path }, flows: [flow] })`.
3551
+ // driven here directly. The helper writes `languages` into EVERY pool's config for you when you
3552
+ // pass it the languages: `bootstrap(registry, groups, { languages: { path } })`.
2504
3553
  const w = new EnvelopeWorker({ worker: echo.url, languages: { path: path.worker } });
2505
3554
  await w.setup();
2506
3555
  const seen = await w.run({ settings: { factor: 10 }, input: { confidence: 0.9, text: 'a bb ccc' }, transform: { lang: 'path', expr: 'confidence' } });
@@ -2513,15 +3562,77 @@ let failed: unknown;
2513
3562
  try { await w.run({ settings: {}, input: { a: 5 }, transform: { lang: 'path', expr: 'a.b' } }); } catch (e) { failed = e; }
2514
3563
  if (!(failed instanceof TransformFailed) || failed.code !== 'P0002') throw new Error('a loud evaluation failure is a TransformFailed carrying the language`s own code — the TASK fails');
2515
3564
  await w.teardown();
3565
+
3566
+ // The CONTEXT, passed through (v0.33): a worker that reads its signal and emits. On a real pool the
3567
+ // scheduler builds the context; here it is built by hand, which the wrapper cannot tell apart.
3568
+ const reads = `data:text/javascript,${encodeURIComponent(`export default class W {
3569
+ run(p, context) { context.emit('seen', { aborted: context.signal.aborted }); return context.signal.reason; } }`)}`;
3570
+ const cw = new EnvelopeWorker({ worker: reads });
3571
+ await cw.setup();
3572
+ const ctl = new AbortController();
3573
+ ctl.abort('stopped by its owner');
3574
+ const emitted: unknown[] = [];
3575
+ const why = await cw.run({ settings: {}, input: null }, { signal: ctl.signal, emit: (name: string, data: unknown) => emitted.push([name, data]) });
3576
+ if (why !== 'stopped by its owner' || JSON.stringify(emitted) !== JSON.stringify([['seen', { aborted: true }]])) throw new Error('a typed worker reaches the signal and the emit its task was handed');
3577
+ await cw.teardown();
3578
+
3579
+ // And the two v0.33 pool options are held to the scheduler's rules where the registry is built.
3580
+ let refused: unknown;
3581
+ try { resolveRegistry({ workers: { echo }, nodeTypes: { Scorer: { worker: 'echo', pool: { cancelGraceMs: -1 } } } }); } catch (e) { refused = e; }
3582
+ if (!(refused instanceof DerivationRefused) || !refused.message.includes('cancelGraceMs')) throw new Error('a negative cancelGraceMs is refused, naming the key');
3583
+ if (resolveRegistry({ workers: { echo }, nodeTypes: { Scorer: { worker: 'echo', pool: { cancelGraceMs: 500, messages: { maxBuffered: 500 } } } } }).pools.get('Scorer')?.spec.cancelGraceMs !== 500) throw new Error('a well-formed one is carried to the pool');
3584
+ let tooLong: unknown;
3585
+ try { resolveRegistry({ workers: { echo }, nodeTypes: { Scorer: { worker: 'echo', pool: { messages: { flushIntervalMs: 2 ** 31 } } } } }); } catch (e) { tooLong = e; }
3586
+ if (!(tooLong instanceof DerivationRefused) || !tooLong.message.includes('flushIntervalMs')) throw new Error('a flush interval no timer can hold (above 2^31-1) is refused');
3587
+
3588
+ // And a NAMED instance of a document type may set its own (v0.37, ADR 0099): merged over its type's, key by key.
3589
+ const perInstance = resolveRegistry({
3590
+ workers: { echo },
3591
+ nodeTypes: { Scorer: { worker: 'echo', pool: { maxThreads: 4, idleTimeout: 30000 } } },
3592
+ pools: { 'scorer-1': { type: 'Scorer', pool: { maxThreads: 1 } } },
3593
+ });
3594
+ const single = perInstance.pools.get('scorer-1')?.spec;
3595
+ if (single?.maxThreads !== 1 || single.idleTimeout !== 30000) throw new Error('the instance`s maxThreads over the type`s, its idleTimeout the type`s');
3596
+ if (perInstance.pools.get('Scorer')?.spec.maxThreads !== 4) throw new Error('and the type`s default pool unmoved');
3597
+ let classInstance: unknown;
3598
+ try { resolveRegistry({ workers: { echo }, pools: { big: { type: 'Gpu', pool: { maxThreads: 1 } } } }, { nodeTypes: { Gpu: { worker: 'echo', pool: () => ({}) } } }); } catch (e) { classInstance = e; }
3599
+ if (!(classInstance instanceof UndeclaredKey)) throw new Error('pool options on a CLASS type`s instance are refused — the class owns its pool');
3600
+
3601
+ // And a schema may ANNOTATE (v0.36, ADR 0098): `title` and `description` a string, `readOnly` and
3602
+ // `writeOnly` a boolean, each held to that shape, at any depth, and read by NO value check.
3603
+ const inputSchema = {
3604
+ type: 'object', title: 'Login', description: 'what the worker reads',
3605
+ properties: { user: { type: 'string', readOnly: true }, password: { type: 'string', writeOnly: true } },
3606
+ };
3607
+ resolveRegistry({ workers: { echo: { ...echo, inputSchema } }, nodeTypes: { Scorer } }); // accepted
3608
+ // writeOnly HIDES NOTHING: the value under it is validated, carried and retained like any other — no redaction.
3609
+ if (findings(inputSchema, { user: 'ada', password: 'hunter2' }).length !== 0) throw new Error('the annotations constrain nothing');
3610
+ const schemaRefusal = (schema: Record<string, unknown>): unknown => {
3611
+ try { resolveRegistry({ workers: { echo: { ...echo, inputSchema: schema } }, nodeTypes: { Scorer } }); } catch (e) { return e; }
3612
+ return undefined;
3613
+ };
3614
+ const shape = schemaRefusal({ type: 'string', writeOnly: 'yes' });
3615
+ if (!(shape instanceof SchemaRefused) || !shape.message.includes('writeOnly')) throw new Error('an annotation of the wrong shape is refused, naming it');
3616
+ const format = schemaRefusal({ type: 'string', format: 'email' });
3617
+ if (!(format instanceof SchemaRefused) || !format.message.includes('"format"')) throw new Error('format reads as a constraint and checks nothing here, so it is refused by name');
2516
3618
  ```
2517
3619
 
3620
+ **A schema may carry four ANNOTATIONS — `title`, `description`, `readOnly`, `writeOnly` — and they
3621
+ constrain nothing** (v0.36, [ADR 0098](doc/decisions/0098-four-annotations-join-the-schema-subset-and-constrain-nothing.md)).
3622
+ Each one's value is held to its shape — a string, a boolean — where the schema is declared, and no
3623
+ check reads it afterwards: **`writeOnly: true` promises NO REDACTION**. A value under it is cloned,
3624
+ kept in the run's event log, checkpointed, kept in run state and handed on exactly as any other, so a secret you do not
3625
+ want retained is redacted by your own code before it reaches a run. `readOnly: true` refuses nothing a
3626
+ caller supplies. `deprecated`, `$comment`, `default`, `format`, `$schema` and `$id` are still refused
3627
+ by name — `default` because nothing applies it, `format` because it would check nothing.
3628
+
2518
3629
  **What this is not.** What a type says about what its worker EXPECTS and RETURNS — input and
2519
3630
  output schemas, and a sample-based check across a transformed edge — was
2520
3631
  [P16](doc/plans/p16-what-a-node-type-is-and-what-the-library-owes-the-scheduler.md)'s plan after
2521
3632
  this one (P18) and is now [P19](doc/plans/p19-what-a-schema-buys-an-author.md)'s, answered in
2522
3633
  **section 15**: since v0.19 a worker DECLARES `inputSchema` and `outputSchema`, no RUN enforces
2523
3634
  them per settlement (that is OUT of P19 as a default) — since v0.21 the two values no static check
2524
- can reach are held to them ONCE each, `FD-R4` (section 9's `BoundaryRefused`), and `checkWiring(registry, flow)`
3635
+ can reach are held to them ONCE each (a transforming task's payload excepted, ADR 0101), `FD-R4` (section 9's `BoundaryRefused`), and `checkWiring(registry, flow)`
2525
3636
  gives every arc of a flow one of five verdicts from them before any run (W73), with `pathsOf`,
2526
3637
  `tryTransform` and `signatureOf` beside it as the authoring assists (W74) — and since v0.20 any schema may carry
2527
3638
  `examples` — an annotation held to its schema at construction ([ADR 0056](doc/decisions/0056-an-example-is-an-annotation-validated-against-the-schema-it-sits-on.md),
@@ -2547,7 +3658,8 @@ The language this package ships for a `transform` is **JSONata**, at the one exa
2547
3658
  [W62](doc/work-items/w62-the-jsonata-adapter.md). **The engine is a dependency of this package and
2548
3659
  is loaded by that sub-path alone**: the root and `fyflow-orchestration/scheduler` import none of it,
2549
3660
  so a consumer who never transforms pays the install and never the load, and a browser bundle carries
2550
- the engine only where the sub-path is imported (237 KiB). Import it, put it in the registry's
3661
+ the engine only where the sub-path is imported (237 KiB). **A runtime whose registry names it loads it
3662
+ in every worker of every typed pool** (ADR 0096 — section 12 has the measured cost). Import it, put it in the registry's
2551
3663
  `languages` under the name a flow's `transform.lang` uses, and hand the same object to the
2552
3664
  bootstrap helper:
2553
3665
 
@@ -2563,7 +3675,7 @@ declare const flow: FlowDefinition;
2563
3675
  // The adapter is `{ compile, worker }`: `compile` runs on the main thread at `new Driver` and
2564
3676
  // `worker` is the URL of the SAME module, which the envelope worker imports inside the pool.
2565
3677
  const languages = { jsonata };
2566
- const built = bootstrap(registry, { scorer: new ConcurrentLimitGroup(4, 'scorer') }, { languages, flows: [flow] });
3678
+ const built = bootstrap(registry, { scorer: new ConcurrentLimitGroup(4, 'scorer') }, { languages });
2567
3679
  const scheduler = new FyflowScheduler(built.workerPools, built.resourceGroups);
2568
3680
  const driver = new Driver(flow, {
2569
3681
  runId: 'run-1', scheduler, predicates: new Map(), joinPolicy: liveToken,
@@ -2651,16 +3763,18 @@ specifier there but the bare `jsonata`. An npm consumer installs one 834 KiB pac
2651
3763
  dependencies of its own, whether or not they ever import the sub-path; a Deno consumer's
2652
3764
  `deno.json` resolves the mapping; a bundler includes the engine only in a bundle that imports the
2653
3765
  sub-path. **A bundled worker is your asset**: inside a bundle `import.meta.url` is the bundle's own
2654
- URL ([Q261](doc/questions/q261.md)), so `jsonata.worker` — like the envelope worker itself — has to be
2655
- served beside your bundle as a module of its own for a pool's thread to load it.
3766
+ URL ([Q261](doc/questions/q261.md)), so `jsonata.worker` has to be served beside your bundle as a module of its own
3767
+ for a pool's thread to load it. **The envelope worker does not**, from npm: the npm dist carries it inside itself, a
3768
+ `data:` URL on Node and a `Blob` URL in the browser. A bundle you build from the JSR SOURCE has to serve it
3769
+ too, compiled, at the `./envelope-worker.ts` URL the source names beside the bundle.
2656
3770
 
2657
3771
  ---
2658
3772
 
2659
3773
  ## 15. What a schema buys an AUTHOR — an authoring session
2660
3774
 
2661
3775
  **The schemas in section 13 were never for the run.** A worker's `inputSchema` and `outputSchema`
2662
- are held to the run's PAYLOAD once and to a person's RESOLUTION once each (`FD-R4`, section 9) and
2663
- never per settlement — and neither of those is *two values per run*, because a fan-out or map entry
3776
+ are held to the run's PAYLOAD once (not where the task declares a `transform`, ADR 0101) and to a person's
3777
+ RESOLUTION once each (`FD-R4`, section 9) and never per settlement — and neither of those is *two values per run*, because a fan-out or map entry
2664
3778
  validates one value per branch task it reaches and a run nobody parks validates no resolution at
2665
3779
  all. So what are they for? **They are for whoever is wiring the flow**, a person or an agent, and this section is
2666
3780
  the session that shows it: a flow written, a MISWIRE caught before anything runs, a transform
@@ -2673,7 +3787,7 @@ adapter's worker half, so it loads a module by URL and returns a promise:
2673
3787
  | | answers |
2674
3788
  |---|---|
2675
3789
  | `pathsOf(schema)` | *what is there to reach for* — every path a value may carry, and which are guaranteed |
2676
- | `tryTransform(registry, flow, node, transform)` | *does THIS expression fit* — one candidate, one verdict, on a real sample |
3790
+ | `tryTransform(registry, flow, node, transform, { input }?)` | *does THIS expression fit* — one candidate, one verdict, on a real sample — or on a value you GIVE, the entry node included (*A sample you GIVE*, below) |
2677
3791
  | `signatureOf(name, worker)` | *what do I write my worker against* — TypeScript, as text |
2678
3792
 
2679
3793
  The registry below is
@@ -2692,8 +3806,7 @@ import { SESSION_BASE, SESSION_REGISTRY, SESSION_WORKERS } from '../../examples/
2692
3806
  // and the languages a transform may be written in. `createRuntime` takes the whole document below.
2693
3807
  const registry = { workers: SESSION_WORKERS, nodeTypes: SESSION_REGISTRY.nodeTypes, languages: { jsonata } };
2694
3808
 
2695
- // THE FLOW, as an author first writes it: read a batch, summarise it. It is a VALID document —
2696
- // `validateDefinition` has nothing to say about it, because nothing here is about the grammar.
3809
+ // THE FLOW, as an author first writes it: read a batch, summarise it. Its grammar is valid.
2697
3810
  const flow: FlowDefinition = {
2698
3811
  flowId: 'session', version: 1, entry: 'read',
2699
3812
  nodes: [
@@ -2718,40 +3831,39 @@ console.log(describePaths(pathsOf(SESSION_WORKERS.reader.outputSchema)));
2718
3831
  // ? $.lines[] string
2719
3832
  // $.source string
2720
3833
 
2721
- // STEP 3 — a first candidate. It is run on the producer's declared EXAMPLE, and the result is held
2722
- // to the consumer's input schema, so the answer is about a value rather than about two declarations.
3834
+ // STEP 3 — a first candidate, run on the producer's declared example and checked against the
3835
+ // consumer's input schema.
2723
3836
  const attempt = await tryTransform(registry, flow, 'sum', { lang: 'jsonata', expr: '{ "text": lines }' });
2724
3837
  console.log(`${attempt[0]!.kind}: ${attempt[0]!.why}`);
2725
3838
  // transform-fails: the transform's result on the example fails the consumer's inputSchema — $.text expected string, got array
2726
3839
  if (attempt[0]!.kind !== 'transform-fails') throw new Error('an array is not a string, and the loop says which');
2727
3840
 
2728
- // STEP 4 — ONE expression edit, and the same loop. `transform-passes` is never `fits`: it is a fact
2729
- // about the sample the verdict names, which is why `sample.source` is on it (a declared example
2730
- // here; where a schema declares none, a generated WITNESS, and the verdict says so).
3841
+ // STEP 4 — one edit, and the same loop. `transform-passes` is about the sample it names, which is
3842
+ // the declared example here, or a generated one where a schema declares none.
2731
3843
  const good = { lang: 'jsonata', expr: '{ "text": $join(lines, " ") }' };
2732
3844
  const passed = await tryTransform(registry, flow, 'sum', good);
2733
3845
  console.log(`${passed[0]!.kind} on the ${passed[0]!.sample!.source}: ${JSON.stringify(passed[0]!.sample!.result)}`);
2734
3846
  if (passed[0]!.kind !== 'transform-passes') throw new Error(`expected the sample to pass, got ${passed[0]!.kind}`);
2735
3847
 
2736
- // STEP 5 — the signature the worker is written against, generated from the same three schemas.
2737
- // `examples/getting-started/session-types.ts` is this text, checked in, and `summarise.ts` imports
2738
- // it. A type holds the SHAPE: `words` is `number` here and its schema says `minimum: 0`.
3848
+ // STEP 5 — the signature the worker is written against, generated from the same schemas.
3849
+ // `examples/getting-started/session-types.ts` is this text, and `summarise.ts` imports it.
2739
3850
  const signature = signatureOf('Summarise', SESSION_WORKERS.summarise);
2740
3851
  console.log(signature.split('\n').filter((l) => l.startsWith('export')).join('\n'));
2741
3852
  if (!signature.includes('"text": string;')) throw new Error('the required key is required in the type too');
2742
3853
 
2743
- // STEP 6 — and only now the runtime. Pools are built ONCE, so a flow whose transform was decided
2744
- // during the session has to reach `createRuntime` as `flows`, or `start` throws `LanguageNotBuilt`
2745
- // naming the type. Check, then build.
3854
+ // STEP 6 — only now the runtime. Check the wired flow against it, then start it; `start` refuses
3855
+ // from that same check.
2746
3856
  const wired: FlowDefinition = { ...flow, nodes: flow.nodes.map((n) => (n.id === 'sum' ? { ...n, transform: good } : n)) };
2747
- const runtime = createRuntime(SESSION_REGISTRY, { languages: { jsonata } }, { base: SESSION_BASE, flows: [wired] });
3857
+ const runtime = createRuntime(SESSION_REGISTRY, { languages: { jsonata } }, { base: SESSION_BASE });
3858
+ const refused = runtime.check(wired);
3859
+ if (refused.length !== 0) throw new Error(`the runtime refuses the wired flow: ${JSON.stringify(refused)}`);
3860
+ const unknownLang: FlowDefinition = { ...flow, nodes: flow.nodes.map((n) => (n.id === 'sum' ? { ...n, transform: { lang: 'nope', expr: '$' } } : n)) };
3861
+ if (!runtime.check(unknownLang).some((f) => f.rule === 'FD-V35' && f.at === 'sum')) throw new Error('a language the registry does not name is FD-V35, at check');
2748
3862
  const state = await runtime.start(wired, { source: 'inbox' }).done;
2749
3863
  await runtime.shutdown();
2750
3864
  console.log(state.outcome, Object.values(state.nodes).find((n) => n.nodeId === 'sum')?.result); // completed { words: 3 }
2751
3865
 
2752
- // The expected values are the DOCUMENT's, not literals beside the run: the consumer's own declared
2753
- // output example, and the producer's declared line count. A registry whose examples stop describing
2754
- // its workers is red here and in `test/definition/assist.test.ts`, which runs the same chain.
3866
+ // Verified by the docs gate:
2755
3867
  if (state.outcome !== 'completed') throw new Error(`expected completed, got ${state.outcome}`);
2756
3868
  const got = Object.values(state.nodes).find((n) => n.nodeId === 'sum')?.result;
2757
3869
  if (JSON.stringify(got) !== JSON.stringify(SESSION_WORKERS.summarise.outputSchema.examples[0])) {
@@ -2798,6 +3910,97 @@ W74's conformity reader, from the author's own scratchpad output.)* Neither hold
2798
3910
  which RUNS each worker on its declared example and validates what came back — is what catches the
2799
3911
  rest.** Even that sees one value: a bound the code does not keep past the example is invisible to it
2800
3912
  and visible at every consumer the wiring check reads ([Q302](doc/questions/q302.md)).
3913
+
3914
+ ### A sample you GIVE — `tryTransform(…, { input })`, the entry node included
3915
+
3916
+ **Every verdict above runs on a SCHEMA's value** — the producer's declared example, or its witness — and
3917
+ the entry node gets none, because no arc reaches it: `tryTransform` on `read` above returns `[]`. An
3918
+ editor has a value in hand instead: typed, pasted, or taken from a run. **Pass it as `input`** and the
3919
+ candidate runs ONCE on it as what the node RECEIVES — `$` — and `tryTransform` returns **ONE verdict
3920
+ whatever arcs reach the node, on the entry node too** (W166, P37 stop-and-ask (d)):
3921
+
3922
+ | | a given verdict |
3923
+ |---|---|
3924
+ | `shape` | `'given'` — not an arc; `checkWiring` never produces it |
3925
+ | `producer` | **absent** — the value came from no node (and `through` too) |
3926
+ | `sample` | `{ source: 'given', value, result }` — the value you gave, and what the transform made of it; no `arity`. **Never `'example'` or `'witness'`** |
3927
+ | `kind` | `transform-passes`, or `transform-fails` with the finding against the node's worker `inputSchema` — or naming the error, where the expression does not compile or throws on this value; `unchecked` for a language, type, worker or worker schema the registry lacks, as on an arc, naming which — and for a `null` candidate, which takes away any transform the node declares too: there is nothing to run the value through, and holding that value to the `inputSchema` is `findings(schema, value)` |
3928
+
3929
+ **The value is `$` as you give it: no `needs` is composed over it**, because a `needs` composes what
3930
+ arrives and this is what arrived — and on a `map`'s branch task it is ONE instance's value, evaluated
3931
+ once. **That includes a `"$payload"` or a `"$item"` key** (since 0.8.0): give the node what it RECEIVES, the payload
3932
+ or the instance's item already under that key — neither function composes one, so a given input and a run agree only when you compose as the run does. `input: undefined` is no input (the arcs answer, as before); `null` is a value. *What it is not*:
3933
+ a statement about the producer or the flow — it says what this expression does to this value, and a
3934
+ value that passes is *the sample passed — the given input*, never *fits*. **`givenVerdict(registry, flow,
3935
+ node, input)`**, exported beside `checkWiring`, is the same road on the node as the flow DECLARES it —
3936
+ `tryTransform` calls it on the copy that carries your candidate, so the two have ONE contract: `input`
3937
+ `undefined` is no input there too, and the node's arcs answer. **`givenVerdict` and `TryTransformOptions` are
3938
+ NEW public surface in 0.8.0.**
3939
+
3940
+ **On an entry that TRANSFORMS, this is the only check of a run's payload before the run.** `start()` does not hold
3941
+ a task that declares a `transform` to its worker's `inputSchema` ([ADR 0101](doc/decisions/0101-a-transforming-task-is-not-held-at-start.md),
3942
+ `FD-R4`, section 9): the schema is about the transform's RESULT, and that exists only in the worker. So *check, then
3943
+ start* — but on the value each transforming TASK receives, because a given value is `$` with no `needs` composed:
3944
+ `boundaryTargets(flow, payload)` lists the tasks the payload reaches and the value reaching each (for a map's branch,
3945
+ each element under `over`; through an entry gate, every route's target), and where the task declares `needs` (at an
3946
+ entry `"."` and, since 0.8.0, `"$payload"`) compose it first — `needs: { own: "." }` receives `{ own: value }`, and
3947
+ `needs: { run: "$payload" }` receives `{ run: payload }`, the PAYLOAD even on a map's branch, where the value is an
3948
+ element — and there `needs: { it: "$item" }` receives `{ it: element }`. Call
3949
+ `givenVerdict(registry, flow, task, received)` on each that transforms; a fan-out, map or gate ENTRY is no task and
3950
+ gives `[]`. Start on `transform-passes`; `unchecked` (no `inputSchema`, or a language, type or worker the registry
3951
+ lacks) checked nothing. Until W170 `start()`
3952
+ held the payload BEFORE the transform, and one value drew opposite verdicts from the two
3953
+ ([Q633](doc/questions/q633.md)).
3954
+
3955
+ ```ts run
3956
+ import { tryTransform, type FlowDefinition } from 'fyflow-orchestration';
3957
+ import { jsonata } from 'fyflow-orchestration/jsonata';
3958
+ import { SESSION_REGISTRY, SESSION_WORKERS } from '../../examples/getting-started/session-registry.ts'; // in your program: './registry.ts', a module or a JSON file of your own
3959
+
3960
+ const registry = { workers: SESSION_WORKERS, nodeTypes: SESSION_REGISTRY.nodeTypes, languages: { jsonata } };
3961
+ const flow: FlowDefinition = {
3962
+ flowId: 'session', version: 1, entry: 'read',
3963
+ nodes: [
3964
+ { id: 'read', kind: 'task', type: 'Reader', edges: [{ to: 'sum' }] },
3965
+ { id: 'sum', kind: 'task', type: 'Summarise', edges: [{ to: 'done' }] },
3966
+ { id: 'done', kind: 'end', outcome: 'completed' },
3967
+ ],
3968
+ };
3969
+
3970
+ // THE ENTRY NODE. No arc reaches it, so the schemas have nothing to say about what it receives...
3971
+ const rename = { lang: 'jsonata', expr: '{ "source": folder }' };
3972
+ if ((await tryTransform(registry, flow, 'read', rename)).length !== 0) throw new Error('no arc reaches the entry');
3973
+ // ...and given what it receives, ONE verdict. `reader`'s inputSchema wants `source` a string.
3974
+ const [entry] = await tryTransform(registry, flow, 'read', rename, { input: { folder: 'inbox' } });
3975
+ console.log(`${entry!.kind} on the ${entry!.sample!.source}: ${JSON.stringify(entry!.sample!.result)}`); // transform-passes on the given: {"source":"inbox"}
3976
+ if (entry!.shape !== 'given' || entry!.producer !== undefined || entry!.sample!.source !== 'given') throw new Error('a given verdict: shape given, no producer');
3977
+ if (entry!.kind !== 'transform-passes') throw new Error(`expected the given value to pass, got ${entry!.kind}: ${entry!.why}`);
3978
+
3979
+ // The same expression on a value it does not fit: the finding, against the node's own inputSchema.
3980
+ const [wrong] = await tryTransform(registry, flow, 'read', rename, { input: { folder: 3 } });
3981
+ console.log(`${wrong!.kind}: ${wrong!.why}`); // transform-fails: the transform's result on the given input fails the consumer's inputSchema — $.source expected string, ...
3982
+ if (wrong!.kind !== 'transform-fails' || !wrong!.why.includes('$.source expected string')) throw new Error(`expected the finding, got ${wrong!.why}`);
3983
+
3984
+ // THE INNER NODE. Without an input its one arc runs on `reader`'s declared EXAMPLE; given a value, it runs on that.
3985
+ const join = { lang: 'jsonata', expr: '{ "text": $join(lines, " ") }' };
3986
+ const [arc] = await tryTransform(registry, flow, 'sum', join);
3987
+ const [mine] = await tryTransform(registry, flow, 'sum', join, { input: { lines: ['typed', 'by', 'hand'] } });
3988
+ console.log(arc!.sample!.source, mine!.sample!.source, mine!.sample!.result); // example given { text: 'typed by hand' }
3989
+ if (arc!.sample!.source !== 'example' || mine!.sample!.source !== 'given') throw new Error('the arc runs on the example, a given value on itself');
3990
+ if (JSON.stringify(mine!.sample!.result) !== JSON.stringify({ text: 'typed by hand' })) throw new Error('joined, the three given lines');
3991
+
3992
+ // An expression the engine refuses: `transform-fails`, naming the throw — the compile error, on the given input.
3993
+ const [broken] = await tryTransform(registry, flow, 'sum', { lang: 'jsonata', expr: '{{{' }, { input: { lines: [] } });
3994
+ if (broken!.kind !== 'transform-fails' || !broken!.why.startsWith('the transform threw on the given input')) throw new Error(`expected the compile error, got ${broken!.why}`);
3995
+ ```
3996
+
3997
+ **BREAKING in 0.8.0** (P37 decision 10): **`SampleSource` gains `'given'`**, **`WiringShape` gains
3998
+ `'given'`** (and `WIRING_SHAPES` with it), and **`WiringVerdict.producer` is optional** — absent on a
3999
+ given verdict and only there. A `switch` exhaustive over the two sources or the five shapes no longer is,
4000
+ and code reading `producer` as a `string` fails to compile under `strictNullChecks` — narrow it on
4001
+ `shape !== 'given'`, or read it as `string | undefined`. `checkWiring`'s own verdicts are unchanged: every
4002
+ one still carries a producer and an `'example'` or `'witness'` sample.
4003
+
2801
4004
  ---
2802
4005
 
2803
4006
  ## 16. What a schema buys a RUN — holding a result to the schema its worker declares
@@ -2905,7 +4108,7 @@ if (wiring.counts.no !== 0) throw new Error('the static check has nothing to say
2905
4108
  // authored, no dispatch moves, and the value reaches the next node exactly as it would with the
2906
4109
  // check off.
2907
4110
  const found: NonconformingResult[] = [];
2908
- const runtime = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4111
+ const runtime = createRuntime(registry, {}, { base: import.meta.url });
2909
4112
  const state = await runtime.start(flow, { text: 'one two three four five six seven eight nine ten eleven' }, {
2910
4113
  onNonconformingResult: (f) => { found.push(f); },
2911
4114
  }).done;
@@ -2941,7 +4144,7 @@ if (after.keys.join() !== 'load,words') throw new Error(`the successor received
2941
4144
  // OMIT THE CALLBACK AND NOTHING IS CHECKED — and the run is the same run. This is the property to
2942
4145
  // rely on: you can turn the reading on in one environment and off in another without changing what
2943
4146
  // the flow DOES, because an instrument that altered what it measures would be useless for auditing.
2944
- const quiet = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4147
+ const quiet = createRuntime(registry, {}, { base: import.meta.url });
2945
4148
  const same = await quiet.start(flow, { text: 'one two three four five six seven eight nine ten eleven' }).done;
2946
4149
  await quiet.shutdown();
2947
4150
  const shape = (s: typeof state) => JSON.stringify(Object.values(s.nodes).map((n) => [n.nodeId, n.result]));
@@ -2983,8 +4186,8 @@ a fan-out's width is an authoring-time fact. Here the outer width is *how many o
2983
4186
  returned* and each inner width is *how many lines THAT order has* — different per instance.
2984
4187
 
2985
4188
  **Writing it needs exactly one node between the two maps.** A `map`'s `over` names a key of what the
2986
- previous node returned, and a `map`'s branch is a TASK (`FD-V25` refuses a map whose branch is a
2987
- map) — so the outer branch entry is the node whose output carries the item's own list. No `needs`, no
4189
+ previous node returned, and a `map`'s branch is a TASK — or, since 0.8.0, an `ask`, which runs no worker (`FD-V25`
4190
+ refuses a map whose branch is a map) — so the outer branch entry is the node whose output carries the item's own list. No `needs`, no
2988
4191
  `transform`, no reshaping node and no second flow.
2989
4192
 
2990
4193
  ```ts run
@@ -3026,7 +4229,7 @@ const flow: FlowDefinition = {
3026
4229
  // A region event now says WHICH INSTANCE it is about. Before 0.4.0 it carried `regionId` alone,
3027
4230
  // which is the same string for every instance of one opener.
3028
4231
  const arrivals: string[] = [];
3029
- const runtime = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4232
+ const runtime = createRuntime(registry, {}, { base: import.meta.url });
3030
4233
  const state = await runtime.start(flow, { region: 'ab c' }, {
3031
4234
  onRegionEvent: (e: RegionEvent) => {
3032
4235
  if (e.type === 'branch.arrived') arrivals.push(`${e.regionId}@${e.regionPath} <- ${e.branchPath}`);
@@ -3099,14 +4302,20 @@ checkpoint is an append to it**. You never have to call `driver.checkpoint()` to
3099
4302
 
3100
4303
  | | where you get it | what you do with it |
3101
4304
  |---|---|---|
3102
- | the header | `runtime.start(…)` returns it as `header`, beside `driver` and `done` | write it once |
4305
+ | the header | **`onHeader(header)`** on `start`'s options — handed to you inside `start`, BEFORE the first event and before anything reaches the scheduler; and `runtime.start(…)` returns the same one as `header`, beside `driver` and `done` | write it once, FIRST |
3103
4306
  | every event | `onFoldInput(event, position)` | write it AT `position` — the driver's index, never a count you start again in each process |
3104
- | a resumed run | `runtime.resume(flow, { header, events }, options)` returns the same `{ driver, done, header }` | keep writing: it hands you positions from the STORED LENGTH on |
3105
-
3106
- **Key the store by POSITION, and the reason is measured.** The first events are delivered WHILE
3107
- `start` runs, so the header arrives after them — a store that must write its header first cannot —
3108
- and a resumed run's first position is the length you stored, not 0, so an arrival counter per process
3109
- overwrites the run it is resuming. ([W90](doc/work-items/w90-a-run-killed-and-resumed-on-real-workers.md)
4307
+ | a resumed run | `runtime.resume(flow, { header, events }, options)` returns the same `{ driver, done, header }` — and calls no `onHeader`: you hold the header already | keep writing: it hands you positions from the STORED LENGTH on |
4308
+
4309
+ **Write the header FIRST, through `onHeader`** (`RN-R39`, [ADR 0112](doc/decisions/0112-a-run-delivers-its-header-before-its-first-event.md),
4310
+ since 0.8.0). The first events are delivered WHILE `start` runs, so the handle's `header` arrives after them, and a process
4311
+ that died between your first event write and that header write left events no resume can read. `onHeader` is called ONCE,
4312
+ before the first `onFoldInput`, with a frozen copy equal to `handle.header` — and **if it throws** (your store could not
4313
+ write it), `start` throws your error with NOTHING started: nothing recorded, nothing handed to the scheduler, nothing
4314
+ reported. A `Driver` you built may be started again; on a runtime that run id is spent, so retry under another (or let it
4315
+ mint one). It is never called on a refused `start` (`BoundaryRefused`, `DataCloneError`, `MultipleRuns`), nor by `resume`.
4316
+
4317
+ **And still key the store by POSITION, and the reason is measured.** A resumed run's first position is the length you
4318
+ stored, not 0, so an arrival counter per process overwrites the run it is resuming. ([W90](doc/work-items/w90-a-run-killed-and-resumed-on-real-workers.md)
3110
4319
  runs exactly that wrong store at L6 and it is refused `diverged` at position 0.) **The contract is
3111
4320
  `structuredClone`, not JSON**: a result is whatever survived `OR-R2`'s clone, which includes `Map`,
3112
4321
  `Set`, `Date`, `BigInt` and `-0` — pick a serializer that keeps them, or revive them.
@@ -3153,9 +4362,12 @@ const store: { header?: CheckpointHeader; events: FoldInput[] } = { events: [] }
3153
4362
  let alive = true;
3154
4363
  let die!: () => void;
3155
4364
  const died = new Promise<void>((resolve) => { die = resolve; });
3156
- const first = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4365
+ const first = createRuntime(registry, {}, { base: import.meta.url });
3157
4366
  const run = first.start(flow, { region: 'ab c' }, {
4367
+ // The header FIRST — called once, inside `start`, before the first event below and before any task is handed on.
4368
+ onHeader: (header) => { store.header = header; },
3158
4369
  onFoldInput: (event, position) => {
4370
+ if (store.header === undefined) throw new Error('an event before the header: the store would hold events no resume can read');
3159
4371
  if (!alive) return; // a dead process writes nothing
3160
4372
  store.events[position] = structuredClone(event); // your durable append, BY POSITION
3161
4373
  // THE CRASH, simulated: the process dies the instant the first `expand` settles — inside the
@@ -3163,9 +4375,9 @@ const run = first.start(flow, { region: 'ab c' }, {
3163
4375
  if (event.kind === 'outcome' && event.record.taskId.includes(':expand:')) { alive = false; die(); }
3164
4376
  },
3165
4377
  });
3166
- // The header arrives on the handle, AFTER the first events: they were delivered while `start` ran.
3167
- // A store that had to write its header first could not; one keyed by position does not care.
3168
- store.header = run.header;
4378
+ // The handle carries the SAME header — it arrives AFTER the first events, which were delivered while `start` ran, and
4379
+ // that is why the store took it from `onHeader` instead.
4380
+ if (JSON.stringify(store.header) !== JSON.stringify(run.header)) throw new Error('onHeader and the handle carry one header');
3169
4381
  await died;
3170
4382
  // `shutdown()` is cleanup, NOT the kill: it lets running work finish, and here that finishes the
3171
4383
  // whole run. What makes this a crash is the store — nothing after `alive = false` reached it.
@@ -3179,7 +4391,7 @@ console.log(`stored ${store.events.length} events, the last one the settlement t
3179
4391
  // keep appending BY POSITION: a resumed run hands you positions from the stored length on, so the
3180
4392
  // same store holds the whole run again.
3181
4393
  const header = store.header!; // written once, by process one
3182
- const second = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4394
+ const second = createRuntime(registry, {}, { base: import.meta.url });
3183
4395
  const resumed = second.resume(flow, { header, events: store.events.slice() }, {
3184
4396
  onFoldInput: (event, position) => { store.events[position] = structuredClone(event); },
3185
4397
  });
@@ -3221,7 +4433,7 @@ try {
3221
4433
  if (!(err instanceof RunIdInUse)) throw err;
3222
4434
  }
3223
4435
  // And a checkpoint is resumed against the definition it was stored under, or not at all.
3224
- const third = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4436
+ const third = createRuntime(registry, {}, { base: import.meta.url });
3225
4437
  try {
3226
4438
  third.resume({ ...flow, version: 2 }, { header, events: store.events.slice() });
3227
4439
  throw new Error('expected ResumeRefused');
@@ -3232,20 +4444,90 @@ await second.shutdown();
3232
4444
  await third.shutdown();
3233
4445
  ```
3234
4446
 
4447
+ **A store that cannot write the header stops the run BEFORE it exists** — `onHeader`'s throw, on a `Driver` you build
4448
+ (section 12's double, so you can see what reaches the scheduler):
4449
+
4450
+ ```ts run
4451
+ import { Driver, NotStarted, liveToken, type CheckpointHeader, type FlowDefinition, type FoldInput } from 'fyflow-orchestration';
4452
+
4453
+ const flow: FlowDefinition = {
4454
+ flowId: 'header-first', version: 1, entry: 'split',
4455
+ nodes: [
4456
+ { id: 'split', kind: 'fanout', branches: ['a', 'b'] },
4457
+ { id: 'a', kind: 'task', type: 'T', edges: [{ to: 'join' }] },
4458
+ { id: 'b', kind: 'task', type: 'T', edges: [{ to: 'join' }] },
4459
+ { id: 'join', kind: 'join', of: 'split', edges: [{ to: 'done' }] },
4460
+ { id: 'done', kind: 'end', outcome: 'completed' },
4461
+ ],
4462
+ };
4463
+ // ONE log: what your store wrote, and what reached the scheduler, in the order it happened.
4464
+ const log: string[] = [];
4465
+ const scheduler = { addTask(task: unknown) { log.push(`handed on ${(task as { id: string }).id}`); }, addEventListener() {} };
4466
+
4467
+ // YOUR store, down for the first attempt.
4468
+ class StoreDown extends Error {}
4469
+ let storeUp = false;
4470
+ const store: { header?: CheckpointHeader; events: FoldInput[] } = { events: [] };
4471
+ const driver = new Driver(flow, {
4472
+ runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken,
4473
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } },
4474
+ onHeader: (header) => {
4475
+ if (!storeUp) throw new StoreDown('the store could not write the header');
4476
+ store.header = header;
4477
+ log.push('header written');
4478
+ },
4479
+ onFoldInput: (event, position) => { store.events[position] = event; log.push(`event ${position} written`); },
4480
+ });
4481
+
4482
+ // The store is down: `start` throws YOUR error, and NOTHING started — no event, nothing handed on.
4483
+ let thrown: unknown = null;
4484
+ try { driver.start({ batch: 7 }); } catch (e) { thrown = e; }
4485
+ console.log(thrown instanceof StoreDown, log); // true []
4486
+ if (!(thrown instanceof StoreDown) || log.length !== 0) throw new Error(`nothing starts when the header cannot be written: ${log.join()}`);
4487
+ let unstarted = false;
4488
+ try { driver.checkpoint(); } catch (e) { unstarted = e instanceof NotStarted; }
4489
+ if (!unstarted) throw new Error('the driver is UNSTARTED — there is no run to checkpoint');
4490
+
4491
+ // The store is back: the SAME driver starts, and the header is written before the first event and the first hand-off.
4492
+ storeUp = true;
4493
+ driver.start({ batch: 7 });
4494
+ console.log(log[0], log.length); // 'header written' 8 — then the five events of an entry fan-out of two and its two hand-offs
4495
+ if (log[0] !== 'header written') throw new Error(`the header is written FIRST: ${log.join(' | ')}`);
4496
+ if (log.filter((l) => l.startsWith('event ')).length !== 5 || log.filter((l) => l.startsWith('handed on ')).length !== 2) {
4497
+ throw new Error(`an entry fan-out of two appends five events and hands on two tasks, all after the header: ${log.join(' | ')}`);
4498
+ }
4499
+ if (JSON.stringify(store.header) !== JSON.stringify(driver.checkpoint().header)) throw new Error('the header written is the run`s');
4500
+ ```
4501
+
3235
4502
  **What a resume PROMISES, and what it does not** — [ADR 0065](doc/decisions/0065-a-resume-replays-the-stored-stimuli-refuses-what-it-cannot-regenerate-and-re-dispatches-what-was-in-flight.md), `spec/run-state.md` section 9:
3236
4503
 
3237
4504
  - **It is AT-LEAST-ONCE, and your workers must tolerate running twice.** The resume REPLAYS the
3238
- stored settlements and resolutions through the routing that produced them, with every dispatch
3239
- held, and then hands every task still in flight to the new scheduler at the SAME task id. That set
4505
+ stored settlements and resolutions through the routing that produced them — and the stored stops, pauses,
4506
+ unpauses, earlier resumes and, since 0.8.0, attempts at their positions — with every dispatch
4507
+ held, and then hands every task still in flight to the new scheduler at the SAME task id — or, for a checkpoint of a
4508
+ PAUSED run, holds each until you unpause the resumed run (`RN-R21` v0.20, section 8). That set
3240
4509
  is what was in flight at the END of the replay — including a task that finished in the dead process
3241
4510
  whose settlement you had not stored yet, and a task the dying settlement dispatched after your last
3242
4511
  write, which the snippet shows the store never saw. The run records it: a `resumed` event naming
3243
4512
  those ids, and `state.resumes`.
3244
- - **A re-run task gets a FRESH retry budget.** The log records a task's `attempts` only when it
3245
- SETTLES, so the attempts a task spent before the crash are recorded nowhere — measured: a task with
3246
- a budget of two attempts ran THREE times across one kill ([Q365](doc/questions/q365.md)).
4513
+ - **A re-run task KEEPS its retry budget** (since 0.8.0 — `RN-R40`,
4514
+ [ADR 0113](doc/decisions/0113-a-tasks-attempts-are-recorded-and-carried-across-a-resume.md)). The run RECORDS each
4515
+ attempt as it starts — one event, `{ kind: 'attempted', taskId, attemptNumber }`, each time the scheduler's
4516
+ `task.running` number for a task of the run RISES (a requeue repeats its number and records nothing) — and the resume
4517
+ hands each task it runs again to the new scheduler with **`attemptsSpent`, the highest number your store holds for it**:
4518
+ the attempt in flight at the crash COUNTS as spent, and so does one a STOP interrupted. A task whose budget is already
4519
+ spent is handed on all the same and runs ONCE more — the scheduler settles it; the library writes no settlement — and a
4520
+ task with no attempt stored gets no key. So the record's `attempts` after a resume counts the attempts of EVERY
4521
+ process, as the scheduler reports it. Two limits: a task whose attempts only the LOST TAIL saw starts fresh, and a
4522
+ `createTask` that builds the task field by field must copy `attemptsSpent` (section 12). *Until 0.8.0 a re-run task got
4523
+ a FRESH budget — a budget of two attempts ran THREE times across one kill ([Q365](doc/questions/q365.md)).*
3247
4524
  - **A task PARKED for a person is not in flight.** It stays parked, the resumed run is
3248
- `awaiting_action`, and `resolve()` on the RESUMED driver completes it (section 7).
4525
+ `awaiting_action`, and `resolve()` on the RESUMED driver completes it (section 7). **So is an ASK**: the replay
4526
+ re-enters it and REGENERATES its `asked` element — the same id and the same kept input, compared with what you stored
4527
+ — handing nothing to the new scheduler; answer it on the resumed driver. **An ask parked in a MAP instance whose
4528
+ successor reads `"$item"`** (section 3) has its instance's item REBUILT by the same replay — the map is re-entered from
4529
+ the stored header and log, and nothing of the item is in your checkpoint — so the answer on the resumed driver reaches a
4530
+ `pay` holding the same invoice the uninterrupted run's would have.
3249
4531
  - **The check sees only what you STORED.** The stored events must be a prefix of what the replay
3250
4532
  regenerates, and the first position where they are not is refused `diverged` — so a predicate or a
3251
4533
  flow you changed is caught where the stored run's road crosses it, and NOT where that road lies
@@ -3253,23 +4535,217 @@ await third.shutdown();
3253
4535
  ([Q372](doc/questions/q372.md)). Change a flow, change its `version`.
3254
4536
  - **The store is TRUSTED for what the resume feeds.** A stored settlement's result and a stored
3255
4537
  person's resolution are replayed as stored — nothing can check they are what the process saw
3256
- ([Q374](doc/questions/q374.md)). A store you do not trust is a store you should not resume from.
4538
+ ([Q374](doc/questions/q374.md)) — and so is a stored `attempted` event's number, which a re-run task is handed as
4539
+ the attempts it SPENT (one naming a task the replay never dispatched, carrying no positive integer, or not RISING over
4540
+ the last stored for its task, is refused `diverged` at its position). A store you do not trust is a store you should not resume from.
3257
4541
  - **`ResumeRefused` names its `reason`**: `format` (not a checkpoint this version writes), `run` (the
3258
4542
  header's run is not the driver's), `flow` (another flow id or `version` — there is no migration),
3259
- `stopped` and `diverged` — the last two with the `index` of the position. **A STOPPED run is not
3260
- resumable**: the refusal that stopped it would stop it again. A completed or `stuck` run resumes to
3261
- itself and dispatches nothing.
4543
+ `halted`, `cancelled` and `diverged` — the last three with the `index` of the position. **A HALTED
4544
+ run is not resumable**: the refusal that halted it would halt it again — and a 0.7.0 checkpoint whose
4545
+ halt is stored under the old kind, `stopped`, is refused `halted` at that element's position (ADR 0102). **Nor is a CANCELLED one**
4546
+ (section 6): a cancel is its owner's act, and a resume would hand the scheduler again what the owner
4547
+ stopped — `index` is the first cancelled settlement or the cancel request, whichever is first. A completed or `stuck` run resumes to itself and
4548
+ dispatches nothing. **A STOPPED run is the one terminal run a resume TAKES** (below): its `interrupted` event is replayed, and `resumed` ends it.
3262
4549
  - **A refused resume may SPEND its run id.** Refused by its header, nothing exists and the id is still
3263
4550
  free; refused during its replay, the runtime built a driver for it — so that runtime refuses the id
3264
4551
  with `RunIdInUse` from then on, as it refuses any id it ever built a driver for, a finished run's
3265
4552
  included, long after it released the driver (section 12). Resume on a fresh runtime
3266
4553
  ([Q371](doc/questions/q371.md)).
4554
+ - **A throw from your `onFoldInput` on the `resumed` element HALTS the resumed run** ([W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md), `RN-R22`): it is
4555
+ reported once, NOTHING of the in-flight set is handed to the new scheduler, and your own error is thrown at
4556
+ `resume()`'s caller — so the stored run, `resumed` and `halted` are a checkpoint refused `halted`, and on a runtime the
4557
+ run id is spent there. A throw on an element the replay regenerated in the LOST TAIL is refused
4558
+ `ResumeRefused('halted')`, as above. *Until W184 it reached you with nothing handed on: `running` for ever.*
3267
4559
  - **`shutdown()` is not a kill.** It DRAINS: measured in node and deno, a run one settlement in
3268
- completes inside it, while a retry waiting on its backoff never runs. It waits while tasks are RUNNING
4560
+ completes inside it, while a retry waiting on its backoff runs only if the backoff ends inside the
4561
+ drain — the drain dispatches like any other moment, and a backoff still pending when it ends is
4562
+ discarded (the current pin's `shutdown()` says so in its source; measured on the scheduler alone by
4563
+ W157 and its conformity reader, in node: a 100 ms backoff beside a 500 ms running task ran its retry
4564
+ in the drain, a 300 ms one with nothing else running did not; at 0.5.2 a retry could also start
4565
+ before its backoff ended). It waits while tasks are RUNNING
3269
4566
  and for at most five seconds, so a run with longer work in flight, or only queued or blocked tasks, is
3270
4567
  left where the wait ended. A crash is what the snippet's
3271
4568
  `alive` flag stands for — the store stops, whatever the process was doing.
3272
4569
 
4570
+ ### Stopping a run to resume it ELSEWHERE — `handle.stop`
4571
+
4572
+ **`handle.stop({ actor, reason?, graceMs? })` — or `driver.stop(…)` on a driver you built — stops ONE run so that a
4573
+ resume on another runtime continues it** (`RN-R33`,
4574
+ [ADR 0105](doc/decisions/0105-a-run-its-owner-stops-goes-deaf-before-it-cancels-and-resumes-elsewhere.md)): an
4575
+ upgrade, a move, *stop them and continue*. In order: the run is HELD and the driver goes **DEAF**; ONE fold input, `{ kind: 'interrupted', actor,
4576
+ reason }`, is appended; and only then is each task IN FLIGHT cancelled. Because it hears nothing from the stop on, no `task.cancelled` of the stop is ever written — **the log ends with those tasks dispatched
4577
+ and unsettled, which is exactly what a resume runs again, at the same ids.** The run reads **`interrupted`** (section 8):
4578
+ terminal here, and the one terminal outcome a resume accepts.
4579
+
4580
+ | | what happens |
4581
+ |---|---|
4582
+ | a task RUNNING, WAITING, in a BACKOFF or not yet picked up | each is cancelled, and its `task.cancelled` is NOT recorded — it stays in flight in the log |
4583
+ | a task PARKED at `user_action` | nothing is sent. **`resolve()` is refused on this runtime from the call on** — the person answers on the runtime that resumes the run |
4584
+ | an ASK waiting for a person (section 7) | the same: nothing is sent, and its answer is refused here. The runtime that resumes the run REGENERATES the ask — the same id, the same kept input — hands its scheduler nothing for it, and takes the answer. An answer given on the SAME step before the stop was called is taken first — its successor is handed to this runtime and cancelled at once, and the resume runs it again, so the answer is not lost (a same-step CANCEL instead drops a pending answer). Stopped while paused, it resumes paused ([W181](doc/work-items/w181-an-ask-under-run-control.md), `RN-R38`) |
4585
+ | a task HELD by a pause (section 8) | sent nothing — the scheduler never had it. It stays in flight in the log, and the checkpoint resumes **PAUSED**: every task in flight held until you unpause there |
4586
+ | `done` | settles with `interrupted` once the stop's CANCELS have resolved — not at the stop, so the old runtime's workers have stopped before you resume elsewhere. The run is released then; a sibling run on the same runtime is untouched |
4587
+ | the run id | still in use on THIS runtime (`RunIdInUse`) — resume on a FRESH one, which is what an upgrade is |
4588
+ | a call from your own callback, mid-step | accepted, and taken when the driver's step ends — so everything that step dispatched is in the log and runs again on resume |
4589
+ | a SECOND call | appends nothing, and forwards its `graceMs` to each task still stopping |
4590
+ | refused | `RunControlRefused` with `control: 'stop'`, by `reason`: `unsupported` (the scheduler has no `cancel`), `not-started`, `halted`, `cancelled` (a cancelled run is not resumable) and `ended` — nothing recorded or sent. And a `driver.cancel` AFTER a stop is refused `interrupted`: cancel it where it resumes |
4591
+ | a task that finished before its cancel arrived | runs AGAIN on the resume — its settlement was never heard. At-least-once, as across a crash |
4592
+ | a task's ATTEMPT that had started | COUNTED as spent, as a crash's is — its `attempted` event is in the log before `interrupted`, so the resume hands the task on with that number as `attemptsSpent`. A stop costs a task one RETRY, never its last run: a task out of retries still runs once (since 0.8.0, `RN-R40`) |
4593
+ | your `onFoldInput` THROWS on the `interrupted` element | every task in flight is still cancelled and every promise settles, in a `finally` — then the run is HALTED and reported once, and the throw reaches `stop()`'s caller (section 9, `RN-R33`). `halted` outranks `interrupted`: the run is NOT resumable, and `done` settles `halted` at once. *Until [W184](doc/work-items/w184-a-consumer-callback-that-throws-on-every-road.md) a stop at rest so thrown stayed `interrupted`, resumable* |
4594
+
4595
+ ```ts run
4596
+ import { Driver, fold, liveToken, resumeRefusal, type FlowDefinition } from 'fyflow-orchestration';
4597
+
4598
+ const flow: FlowDefinition = {
4599
+ flowId: 'stop-run', version: 1, entry: 'split',
4600
+ nodes: [
4601
+ { id: 'split', kind: 'fanout', branches: ['a', 'b', 'review'] },
4602
+ { id: 'a', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
4603
+ { id: 'b', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
4604
+ { id: 'review', kind: 'task', type: 'T', edges: [{ to: 'collect' }] },
4605
+ { id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'done' }] },
4606
+ { id: 'done', kind: 'end', outcome: 'completed' },
4607
+ ],
4608
+ };
4609
+
4610
+ // A fake scheduler with the scheduler's own `cancel`: the stopped worker fires `task.cancelled`, then cancel() resolves.
4611
+ function fakeScheduler() {
4612
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
4613
+ const added: string[] = [];
4614
+ const stopping = new Map<string, (stopped: boolean) => void>();
4615
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
4616
+ const settle = (id: string, state: 'done' | 'user_action') => {
4617
+ if (state === 'done') fire('task.completed', { id, state, result: { ok: true } });
4618
+ else { fire('task.failed', { id, state, error: 'a person must look' }); fire('task.user_action', { id, state }); }
4619
+ };
4620
+ return {
4621
+ added, settle,
4622
+ stops: (id: string) => { fire('task.cancelled', { id, state: 'cancelled' }); stopping.get(id)?.(true); },
4623
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
4624
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
4625
+ cancel(id: string) { return new Promise<boolean>((resolve) => { stopping.set(id, resolve); }); },
4626
+ };
4627
+ }
4628
+ const options = {
4629
+ runId: 'run-1', createTask: (c: unknown) => c, predicates: new Map(),
4630
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } }, joinPolicy: liveToken,
4631
+ };
4632
+
4633
+ // RUNTIME A: a and b running, review parked on a person.
4634
+ const oldScheduler = fakeScheduler();
4635
+ const driver = new Driver(flow, { ...options, scheduler: oldScheduler });
4636
+ driver.start({});
4637
+ oldScheduler.settle('run-1:review:2:0', 'user_action');
4638
+
4639
+ const stopped = driver.stop({ actor: 'ops', reason: 'upgrade' });
4640
+ console.log(fold(driver.foldInputs()).outcome); // interrupted
4641
+ if (fold(driver.foldInputs()).outcome !== 'interrupted') throw new Error('a stopped run reads `interrupted`');
4642
+ if (driver.resolve('run-1:review:2:0', 'done', { actor: 'ana', result: null })) throw new Error('resolve() is refused here');
4643
+
4644
+ // The workers stop. Their `task.cancelled` reaches a DEAF driver: the element is still the last thing in the log.
4645
+ oldScheduler.stops('run-1:a:0:0');
4646
+ oldScheduler.stops('run-1:b:1:0');
4647
+ await stopped;
4648
+ if (driver.foldInputs().at(-1)?.kind !== 'interrupted') throw new Error('no task.cancelled of a stop is recorded');
4649
+
4650
+ // RUNTIME B: a fresh driver, resumed from the stored checkpoint.
4651
+ const checkpoint = structuredClone(driver.checkpoint());
4652
+ if (resumeRefusal(flow, checkpoint) !== null) throw new Error('a stopped run is resumable');
4653
+ const newScheduler = fakeScheduler();
4654
+ const resumed = new Driver(flow, { ...options, scheduler: newScheduler });
4655
+ resumed.resume(checkpoint);
4656
+ console.log(newScheduler.added); // [ 'run-1:a:0:0', 'run-1:b:1:0' ] — exactly what was in flight, at the same ids
4657
+ if (newScheduler.added.join() !== 'run-1:a:0:0,run-1:b:1:0') throw new Error('the in-flight set runs again');
4658
+ // The person answers HERE, and the run completes.
4659
+ if (!resumed.resolve('run-1:review:2:0', 'done', { actor: 'ana', result: { ok: true } })) throw new Error('resolved on B');
4660
+ newScheduler.settle('run-1:a:0:0', 'done');
4661
+ newScheduler.settle('run-1:b:1:0', 'done');
4662
+ const state = fold(resumed.foldInputs());
4663
+ console.log(state.outcome, state.interruption); // completed null
4664
+ if (state.outcome !== 'completed' || state.interruption !== null) throw new Error('the resume ended the stop, and the run completed');
4665
+ ```
4666
+
4667
+ On a runtime you call `handle.stop(…)` and wait for `handle.done`, which settles `interrupted` once the workers have
4668
+ stopped; then you resume the checkpoint on a fresh runtime, as the snippet above does after a crash.
4669
+
4670
+ **BREAKING in 0.8.0** (P34 decision 10): `RunOutcome` gains `'interrupted'` and `FoldInput` the kind `'interrupted'`,
4671
+ so an exhaustive `switch` over either no longer compiles; `RunState` gains the cell `interruption` (`null` unless the
4672
+ run is stopped NOW — a `resumed` event ends it), which a `RunState` literal must carry; `RunControlRefused.control` gains
4673
+ `'stop'` and its `reason` `'interrupted'`; and `RunHandle` gains `stop`, which breaks only code that builds a handle.
4674
+
4675
+ ### What a run records of each ATTEMPT — and what a resume hands on
4676
+
4677
+ **Each attempt a task STARTS is one event in the run, and a resume hands the count on** (`RN-R40`,
4678
+ [ADR 0113](doc/decisions/0113-a-tasks-attempts-are-recorded-and-carried-across-a-resume.md), since 0.8.0). The driver
4679
+ hears the scheduler's `task.running` and appends `{ kind: 'attempted', taskId, attemptNumber }` — the scheduler's
4680
+ 1-based number — for a task of its run, each time that number RISES: a requeue (a worker died under the task, and the
4681
+ pool ran it again) repeats its number and records nothing. Every task, with a `retryPolicy` or without. It is a FOLD
4682
+ INPUT, never a log record, and it moves no outcome and no state — only its task's `attemptNumber` on the `nodes` entry
4683
+ (section 8, since W187). What it changes besides is what a resume does: each task it runs again is handed on with
4684
+ `attemptsSpent`, the highest number your store holds for it.
4685
+
4686
+ | | what happens |
4687
+ |---|---|
4688
+ | an inline pool starts the task INSIDE the driver's `addTask` | the event is appended when the driver's step ENDS — after everything that step recorded, before a pause or a stop called on it — so it always sits between two steps, where a resume can feed it |
4689
+ | an id of another run, an id no driver minted, a task the run never dispatched, a detail with no attempt number | nothing recorded and nothing reported — an attempt never stops a run |
4690
+ | a stopped driver (section 18's stop) | records nothing more: the attempt running at the stop is in the log, one after it is not |
4691
+ | your `onFoldInput` THROWS on the event | section 9's rule: the run halted, reported once, and on your own call's step thrown to you |
4692
+
4693
+ ```ts run
4694
+ import { Driver, fold, liveToken, type FlowDefinition } from 'fyflow-orchestration';
4695
+
4696
+ const flow: FlowDefinition = {
4697
+ flowId: 'attempts', version: 1, entry: 'fetch',
4698
+ nodes: [
4699
+ { id: 'fetch', kind: 'task', type: 'T', retryPolicy: { maxRetries: 2, backoffMs: 1000 }, edges: [{ to: 'done' }] },
4700
+ { id: 'done', kind: 'end', outcome: 'completed' },
4701
+ ],
4702
+ };
4703
+ // A fake scheduler: it records what it is handed, and a test says when an attempt STARTS — the scheduler's
4704
+ // `task.running`, with its 1-based `attemptNumber`.
4705
+ function fakeScheduler() {
4706
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
4707
+ const added: Record<string, unknown>[] = [];
4708
+ return {
4709
+ added,
4710
+ starts: (id: string, attemptNumber: number) => {
4711
+ for (const fn of listeners.get('task.running') ?? []) fn({ detail: { id, state: 'running', attemptNumber } });
4712
+ },
4713
+ addTask(task: unknown) { added.push(task as Record<string, unknown>); },
4714
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
4715
+ };
4716
+ }
4717
+ const options = {
4718
+ runId: 'run-1', createTask: (c: unknown) => c, predicates: new Map(),
4719
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { T: { worker: 'w' } }, joinPolicy: liveToken,
4720
+ };
4721
+
4722
+ // PROCESS ONE: `fetch` fails its first attempt and starts its second — and then the process dies.
4723
+ const first = fakeScheduler();
4724
+ const driver = new Driver(flow, { ...options, scheduler: first });
4725
+ driver.start({});
4726
+ first.starts('run-1:fetch:-:0', 1);
4727
+ first.starts('run-1:fetch:-:0', 2);
4728
+ first.starts('run-1:fetch:-:0', 2); // a worker died under it and the pool REQUEUED it: the same number, nothing recorded
4729
+ const attempts = driver.foldInputs().filter((e) => e.kind === 'attempted');
4730
+ console.log(attempts.length); // 2 — one per attempt STARTED
4731
+ if (attempts.length !== 2) throw new Error('one event per attempt started, and none for the requeue');
4732
+ if (driver.snapshot().length !== 0) throw new Error('an attempt is a fold input, never a log record');
4733
+ if (fold(driver.foldInputs()).outcome !== 'running') throw new Error('and it moves no outcome');
4734
+ if (fold(driver.foldInputs()).nodes['run-1:fetch:-:0']!.attemptNumber !== 2) throw new Error('only the entry`s attemptNumber (section 8)');
4735
+
4736
+ // PROCESS TWO: resumed from the stored events — `fetch` is handed on with the two attempts it SPENT.
4737
+ const second = fakeScheduler();
4738
+ new Driver(flow, { ...options, scheduler: second }).resume(structuredClone(driver.checkpoint()));
4739
+ console.log(second.added[0]?.attemptsSpent); // 2 — the scheduler runs it as attempt 3, its LAST, not as attempt 1
4740
+ if (second.added[0]?.attemptsSpent !== 2) throw new Error('the retry budget survives the crash');
4741
+ ```
4742
+
4743
+ **BREAKING in 0.8.0** (P36 (p)): `FoldInput` gains the kind `'attempted'`, so an exhaustive `switch` over `kind` no
4744
+ longer compiles; a run on a real scheduler appends one more event per attempt started, so your store grows by about one
4745
+ event per task; and a resumed task is handed on with `attemptsSpent`, so its retries are not reset and the record's
4746
+ `attempts` counts every process's. Additive: `TaskFactory`'s config gains the optional `attemptsSpent` — which a
4747
+ `createTask` building the task field by field must copy (section 12).
4748
+
3273
4749
  **`0.5.0` is BREAKING for a consumer of the fold, in two ways.** `FoldInput` has a new kind,
3274
4750
  `resumed`, so a `switch` that is exhaustive over `kind` no longer compiles, and `RunState` has a new
3275
4751
  cell, `resumes`, so a `RunState` you construct needs it. A run nobody resumes carries no `resumed`
@@ -3292,7 +4768,7 @@ rest, while every file gets a thumbnail* — had nowhere to keep that memory.
3292
4768
 
3293
4769
  | | |
3294
4770
  |---|---|
3295
- | **it names a gate TYPE** | `distinct` is built in: routes `first` (its DEFAULT), `duplicate` and `nokey`, keyed by `settings.key` — a DOTTED path of object keys over the value that reached the gate, no index, no wildcard, no expression. A key a flow needs COMPUTED is computed by the task before the gate |
4771
+ | **it names a gate TYPE** | `distinct` is built in: routes `first` (its DEFAULT), `duplicate` and `nokey`, keyed by `settings.key` — a DOTTED path of object keys over the value that reached the gate, no index, no wildcard, no expression. A key a flow needs COMPUTED is computed by the task before the gate. `if` and `switch` are built in too, and remember nothing (the end of this section) |
3296
4772
  | **every route is wired, exactly once** | an edge with no `route` carries the type's DEFAULT route, never every route; a DROP is an edge to an `end`, and each is refused at load if it is missing (`FD-V41`) |
3297
4773
  | **it computes nothing** | the branch leaves on the chosen route with EXACTLY the value that reached the gate |
3298
4774
  | **its memory is ONE per gate per RUN** | shared by every branch that reaches it, whatever map item or region instance it comes from — and it starts empty with the run and ends with it |
@@ -3303,6 +4779,7 @@ rest, while every file gets a thumbnail* — had nowhere to keep that memory.
3303
4779
  ```ts run
3304
4780
  import { createRuntime } from 'fyflow-orchestration/scheduler';
3305
4781
  import type { FlowDefinition, GateType, RegistryDocument } from 'fyflow-orchestration';
4782
+ import { validateDefinition } from 'fyflow-orchestration';
3306
4783
 
3307
4784
  const registry: RegistryDocument = {
3308
4785
  workers: {
@@ -3351,7 +4828,7 @@ const flow: FlowDefinition = {
3351
4828
 
3352
4829
  const files = ['Dark.S01E01.mkv', 'Lost.S01E01.mkv', 'Dark.S01E02.mkv', 'trailer.mkv', 'Lost.S01E02.mkv']
3353
4830
  .map((name) => ({ name, sizeMB: 1 }));
3354
- const runtime = createRuntime(registry, {}, { base: import.meta.url, flows: [flow] });
4831
+ const runtime = createRuntime(registry, {}, { base: import.meta.url });
3355
4832
  const state = await runtime.start(flow, { files }).done;
3356
4833
  await runtime.shutdown();
3357
4834
 
@@ -3404,14 +4881,41 @@ const filter: FlowDefinition = {
3404
4881
  { id: 'done', kind: 'end', outcome: 'completed' },
3405
4882
  ],
3406
4883
  };
3407
- const second = createRuntime(registry, { gateTypes: { sloppy } }, { base: import.meta.url, flows: [filter] });
4884
+ const second = createRuntime(registry, { gateTypes: { sloppy } }, { base: import.meta.url });
3408
4885
  const kinds: string[] = [];
3409
- const stopped = await second.start(filter, { name: 'Dark.S01E01.mkv', sizeMB: 1 }, { onRunFailure: (f) => kinds.push(f.kind) }).done;
4886
+ const halted = await second.start(filter, { name: 'Dark.S01E01.mkv', sizeMB: 1 }, { onRunFailure: (f) => kinds.push(f.kind) }).done;
3410
4887
  await second.shutdown();
3411
- console.log(stopped.outcome, kinds); // stopped [ 'UndeclaredRoute' ]
3412
- if (stopped.outcome !== 'stopped' || kinds.join() !== 'UndeclaredRoute' || stopped.gated.length !== 0) {
3413
- throw new Error(`an undeclared route stops the run by name, recording nothing: ${stopped.outcome} ${kinds.join()}`);
4888
+ console.log(halted.outcome, kinds); // halted [ 'UndeclaredRoute' ]
4889
+ if (halted.outcome !== 'halted' || kinds.join() !== 'UndeclaredRoute' || halted.gated.length !== 0) {
4890
+ throw new Error(`an undeclared route halts the run by name, recording nothing: ${halted.outcome} ${kinds.join()}`);
3414
4891
  }
4892
+
4893
+ // A type whose routes are its GATE's — v0.30, ADR 0092. `routesFor` IN PLACE of `routes` and `defaultRoute`: each
4894
+ // gate's routes come from its OWN settings, so FD-V41 counts each gate against its own — a case left unwired is
4895
+ // refused at load naming the case. `settingsProblem` says what a settingsSchema cannot (FD-V40).
4896
+ const byCase: GateType = {
4897
+ settingsProblem: (s) => (Array.isArray(s.cases) && s.cases.every((c) => typeof c === 'string') ? null : 'cases is a list of route names'),
4898
+ routesFor: (s) => ({ routes: [...(s.cases as string[]), 'other'], defaultRoute: 'other' }),
4899
+ fn: (ctx) => (typeof ctx.key === 'string' && (ctx.settings.cases as string[]).includes(ctx.key) ? ctx.key : 'other'),
4900
+ };
4901
+ const sorter = (edges: { to: string; route?: string }[]): FlowDefinition => ({
4902
+ flowId: 'sorter', version: 1, entry: 'sort',
4903
+ nodes: [
4904
+ { id: 'sort', kind: 'gate', type: 'byCase', settings: { key: 'kind', cases: ['video', 'audio'] }, edges },
4905
+ { id: 'kept', kind: 'end', outcome: 'kept' },
4906
+ { id: 'rest', kind: 'end', outcome: 'rest' },
4907
+ ],
4908
+ });
4909
+ const withByCase = { predicates: [], gateTypes: { byCase } };
4910
+ const wired = validateDefinition(sorter([{ route: 'video', to: 'kept' }, { route: 'audio', to: 'kept' }, { to: 'rest' }]), withByCase);
4911
+ if (wired.length !== 0) throw new Error(`every case wired, and the default by an edge with no route: ${JSON.stringify(wired)}`);
4912
+ const forgot = validateDefinition(sorter([{ route: 'video', to: 'kept' }, { to: 'rest' }]), withByCase);
4913
+ console.log(forgot.map((f) => `${f.rule} ${f.at}`)); // [ 'FD-V41 sort' ]
4914
+ if (forgot.length !== 1 || forgot[0]!.rule !== 'FD-V41' || !forgot[0]!.detail.includes('"audio"')) {
4915
+ throw new Error(`the unwired case is refused at load, by name: ${JSON.stringify(forgot)}`);
4916
+ }
4917
+ const malformed = validateDefinition({ ...sorter([{ to: 'rest' }]), nodes: [{ id: 'sort', kind: 'gate', type: 'byCase', settings: { cases: [1] }, edges: [{ to: 'rest' }] }, { id: 'rest', kind: 'end', outcome: 'rest' }] }, withByCase);
4918
+ if (malformed.map((f) => f.rule).join() !== 'FD-V40') throw new Error(`settingsProblem's message is FD-V40: ${JSON.stringify(malformed)}`);
3415
4919
  ```
3416
4920
 
3417
4921
  **What the snippet cannot show, and you need:**
@@ -3436,11 +4940,106 @@ if (stopped.outcome !== 'stopped' || kinds.join() !== 'UndeclaredRoute' || stopp
3436
4940
  route is what arrived with `required` added along the gate's key path, at every step. So a worker after `first`
3437
4941
  that REQUIRES the key `fits`. *It read `no` there until v0.25, which is [Q391](doc/questions/q391.md) and is
3438
4942
  [ADR 0068](doc/decisions/0068-a-route-guarantees-what-its-type-declares-and-the-library-derives-it-from-the-key-path.md).*
4943
+ - **A type's routes may come from its GATE — v0.30.** A gate type declares `routes` and `defaultRoute`
4944
+ (`keyResolvedRoutes` beside them) as `distinct` does, OR `routesFor(settings)` returning
4945
+ `{ routes, defaultRoute, keyResolvedRoutes? }` for one gate — exactly one of the two; both or neither is
4946
+ `GateTypeRefused`. `GateType` is `StaticGateType | DerivedGateType`, so code that READS `type.routes` off a
4947
+ `GateType` gets `readonly string[] | undefined`: ask `routesOf(type, settings)`, which returns the gate's set or
4948
+ a `{ problem }` and never throws. `routesFor` is asked once per gate at load, after its settings passed, and
4949
+ once per gate by each driver — keep it the pure function of its settings `fn` is of its context. A throw or a
4950
+ malformed set is `FD-V39` at that gate; a message from `settingsProblem` is `FD-V40`
4951
+ ([ADR 0092](doc/decisions/0092-a-gate-types-routes-are-declared-statically-or-derived-from-its-node.md)).
3439
4952
  - **A route that guarantees something else guarantees nothing a check can use.** A type declaring no
3440
4953
  `keyResolvedRoutes`, a gate naming no `key`, and a route the type does not name each pass the value through
3441
4954
  exactly as before — and the guarantee is PRESENCE along the path, never the leaf's TYPE, which is the type's own
3442
4955
  judgement (`distinct` wants a string or a finite number, and no static tool derives that).
3443
4956
 
4957
+ ### Routing with NO code — the built-in `if` and `switch`
4958
+
4959
+ **A gate need not remember anything.** Since `spec/flow-definition.md` v0.31
4960
+ ([P32](doc/plans/p32-what-a-gate-may-decide-without-memory.md), [W154](doc/work-items/w154-the-built-in-if-and-switch.md))
4961
+ two more types are built in, and they route the value that reached the gate by its SETTINGS alone — so a flow can
4962
+ route its ENTRY payload, or the value straight after a gate, with no worker before the decision and nothing
4963
+ registered:
4964
+
4965
+ | | `if` | `switch` |
4966
+ |---|---|---|
4967
+ | **settings** | `{ when }` — a comparison or a flat `all`/`any` group, section 4's grammar, whose paths start at **`input`**: the value that reached the gate | `{ key, cases }` — `key` a key path as `distinct`'s, `cases` a map from a CASE NAME to a scalar or a non-empty array of scalars |
4968
+ | **routes** | `then`, and `else` — the DEFAULT, so an edge with no `route` is the fallback | every case name, then `default` — the DEFAULT. So a case left unwired is refused at load, NAMING the case (`FD-V41`) |
4969
+ | **matches** | `when` holds over `{ input }`, by the evaluator a task edge's `when` uses — the same operators, absent rule, `default` and `length` | the case one of whose values is `===` the resolved key: `"1"` and `1` are two values, and a value in two cases is refused at load |
4970
+ | **refused at load (`FD-V40`)** | a registered predicate NAME (a gate has no outcome to hand it), a root other than `input` — or `input` alone — and every `when` defect section 4 refuses, named | a case named `default` or outside the route charset, a value that is not a scalar, an empty array, a value in two cases or twice in one, an empty `cases` |
4971
+
4972
+ - **Neither reads the gate's memory**: two branches with one key take the same route at a `switch`, where
4973
+ `distinct` sends the second to `duplicate`.
4974
+ - **`input` is an `if`'s root and nobody else's.** A task's edge still roots at `outcome` and `needs`; `input.x`
4975
+ there is `FD-V43`.
4976
+ - **`switch`'s cases are key-resolved routes** (ADR 0068), so `checkWiring` refines what leaves on a case along
4977
+ the key, as it does for `distinct`'s `first`. **An `if` guarantees nothing**: a task after `then` that requires
4978
+ the key the condition compared reads `no` against a producer that does not REQUIRE it.
4979
+ - **Neither has a named export** — `if` and `switch` are words a `const` cannot carry. Reach them as a flow does,
4980
+ by name: `BUILT_IN_GATE_TYPES.if`, `BUILT_IN_GATE_TYPES.switch`. A type of your own may not take either name
4981
+ (`NameCollision`).
4982
+
4983
+ ```ts run
4984
+ import { Driver, fold, liveToken, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
4985
+
4986
+ // The ENTRY is an `if` over the payload; `classify` runs only on the escalated road, and a `switch` routes its
4987
+ // result. No predicate and no gate type is registered anywhere below.
4988
+ const flow: FlowDefinition = {
4989
+ flowId: 'triage', version: 1, entry: 'triage',
4990
+ nodes: [
4991
+ { id: 'triage', kind: 'gate', type: 'if', settings: { when: { path: 'input.priority', op: 'gte', value: 5 } },
4992
+ edges: [{ route: 'then', to: 'classify' }, { to: 'queue' }] }, // no route: else, the default
4993
+ { id: 'classify', kind: 'task', type: 'Work', edges: [{ to: 'byStatus' }] },
4994
+ { id: 'byStatus', kind: 'gate', type: 'switch',
4995
+ settings: { key: 'status', cases: { approved: 'approved', rework: ['rejected', 'needs-changes'] } },
4996
+ edges: [{ route: 'approved', to: 'publish' }, { route: 'rework', to: 'revise' }, { to: 'review' }] },
4997
+ { id: 'queue', kind: 'task', type: 'Work', edges: [{ to: 'done' }] },
4998
+ { id: 'publish', kind: 'task', type: 'Work', edges: [{ to: 'done' }] },
4999
+ { id: 'revise', kind: 'task', type: 'Work', edges: [{ to: 'done' }] },
5000
+ { id: 'review', kind: 'task', type: 'Work', edges: [{ to: 'done' }] },
5001
+ { id: 'done', kind: 'end', outcome: 'completed' },
5002
+ ],
5003
+ };
5004
+
5005
+ // One run per payload, on the twelve-line fake scheduler section 12 explains.
5006
+ function run(payload: unknown, classified?: unknown): string[] {
5007
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
5008
+ const added: string[] = [];
5009
+ const failures: string[] = [];
5010
+ const scheduler = {
5011
+ addTask(task: unknown) { added.push((task as { id: string }).id); },
5012
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
5013
+ };
5014
+ const driver = new Driver(flow, {
5015
+ runId: 'r', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken,
5016
+ workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } },
5017
+ onRunFailure: (f) => { failures.push(f.kind); },
5018
+ });
5019
+ driver.start(payload);
5020
+ if (classified !== undefined) for (const fn of listeners.get('task.completed') ?? []) fn({ detail: { id: 'r:classify:-:0', state: 'done', result: classified } });
5021
+ if (failures.length) throw new Error(`the run reported ${failures.join(', ')}`);
5022
+ const gated = fold(driver.foldInputs()).gated.map((d) => `${d.gateId}:${d.route}`);
5023
+ return [...gated, ...added.map((id) => id.split(':')[1]!)];
5024
+ }
5025
+
5026
+ console.log(run({ priority: 2 })); // [ 'triage:else', 'queue' ]
5027
+ console.log(run({ priority: 7 }, { status: 'needs-changes' })); // [ 'triage:then', 'byStatus:rework', 'classify', 'revise' ]
5028
+ if (run({ priority: 2 }).join() !== 'triage:else,queue') throw new Error('priority 2 is below 5: else');
5029
+ if (run({ priority: '9' }).join() !== 'triage:else,queue') throw new Error('"9" is not a number: no coercion, so else');
5030
+ if (run({ priority: 7 }, { status: 'approved' }).join() !== 'triage:then,byStatus:approved,classify,publish') throw new Error('approved');
5031
+ if (run({ priority: 7 }, { status: 'needs-changes' }).join() !== 'triage:then,byStatus:rework,classify,revise') throw new Error('a value in an array case');
5032
+ if (run({ priority: 7 }, { status: 'pending' }).join() !== 'triage:then,byStatus:default,classify,review') throw new Error('no case: default');
5033
+
5034
+ // A case left unwired is refused at LOAD, naming it — the forgotten case an edge chain of comparisons cannot catch.
5035
+ // (`revise` goes too, or it is unreachable and FD-V11 says so beside it.)
5036
+ const forgot = { ...flow, nodes: flow.nodes.filter((n) => n.id !== 'revise')
5037
+ .map((n) => (n.id === 'byStatus' ? { ...n, edges: [{ route: 'approved', to: 'publish' }, { to: 'review' }] } : n)) } as FlowDefinition;
5038
+ const found = validateDefinition(forgot, { predicates: [], workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } } });
5039
+ console.log(found.map((f) => `${f.rule} ${f.at}`)); // [ 'FD-V41 byStatus' ]
5040
+ if (found.length !== 1 || found[0]!.rule !== 'FD-V41' || !found[0]!.detail.includes('"rework"')) throw new Error(`the unwired case, by name: ${JSON.stringify(found)}`);
5041
+ ```
5042
+
3444
5043
  **`0.6.0` is BREAKING in four ways, all of them for code that enumerates or matches.** `FlowNode` has a SIXTH kind,
3445
5044
  `GateNode` ([Q381](doc/questions/q381.md)), so a `switch` exhaustive over `node.kind` no longer compiles;
3446
5045
  `FoldInput` has a new kind, `gated`, so one exhaustive over `input.kind` no longer compiles either; `RunState` has a
@@ -3751,7 +5350,8 @@ reported rather than hidden. And a declared predicate's PATH is checked against
3751
5350
  a path naming a key the producer cannot emit answers *no match* for ever (ADR 0074 consequence 6).
3752
5351
 
3753
5352
  > **A `Transform` node's realm and its groups are the scheduler's default and nothing else.** The
3754
- > built-in declares no `inline`, no `groups` and no `pool`, and a named pool instance carries only
3755
- > `type` and `config` — so of the four things ADR 0075 rule 3 lists, a named instance reaches
3756
- > `config`, a `retryPolicy` goes on the NODE, and `inline` and `groups` are reachable by no road a
3757
- > consumer can write ([Q457](doc/questions/q457.md)). Measured at W110.
5353
+ > built-in declares no `inline`, no `groups` and no `pool`, and a named pool instance carries `type`,
5354
+ > `config` and — since v0.37 ([ADR 0099](doc/decisions/0099-a-named-instance-of-a-document-type-may-declare-its-own-pool-options.md)) — `pool`; so of the four things ADR 0075 rule 3
5355
+ > lists, a named instance reaches `config` and `pool` (its options, `residentGroups` included: a transform-only pool
5356
+ > CAN now hold a resident group, [Q626](doc/questions/q626.md)), a `retryPolicy` goes on the NODE, and `inline` and
5357
+ > `groups` are reachable by no road a consumer can write ([Q457](doc/questions/q457.md)). Measured at W110 and W165.