fyflow-orchestration 0.11.0 → 0.12.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
@@ -218,11 +218,12 @@ if (v48.join() !== 'FD-V48') throw new Error(`expected FD-V48, got ${v48.join()}
218
218
  >
219
219
  > **What each instance receives, and what the join hands on.** Each instance is dispatched with
220
220
  > **its own item** — `over`'s array at that instance's index, as its own copy, so no two instances
221
- > share an object — and the join carries **every item's result keyed by that item's branch path**. A
222
- > map of three items at the top level dispatches three tasks and its successor receives
223
- > `{ "0": …, "1": …, "2": … }`; **a map nested in a fan-out branch keys its collection with the whole
224
- > path**, so a map inside branch 1 gives `{ "1.0": …, "1.1": … }`. *(This said "keyed by its item
225
- > index", which is the depth-1 case stated as if it were general — W34's conformity reader.)*
221
+ > share an object — and the join carries **every item's result keyed by that item's INDEX**, one
222
+ > segment, wherever the map sits: a map of three items dispatches three tasks and its successor
223
+ > receives `{ "0": …, "1": …, "2": … }`, and so does a map nested in a fan-out branch. **Since 0.12.0**
224
+ > ([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md)): until then a nested map keyed its collection with the whole path — a map
225
+ > inside branch 1 gave `{ "1.0": …, "1.1": … }` — and *What a join carries downstream* in section 5 says
226
+ > what moved with it.
226
227
  > [ADR 0037](doc/decisions/0037-what-a-branch-receives-is-a-property-of-the-fan-out-and-what-a-join-carries-is-a-property-of-the-region.md),
227
228
  > and see *What a join carries downstream* in section 5 for what happens when an item's branch does
228
229
  > not arrive.
@@ -292,7 +293,7 @@ if (!noGroups.some((f) => f.rule === 'FD-V31' && f.at === 'score')) {
292
293
  | `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`) |
293
294
  | `end` | terminal. `outcome` is **your label**, never interpreted | — |
294
295
  | `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 |
296
+ | `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`, and since 0.12.0 an array element by a bracket index, `files[0].series` (a plain `xs.1` is still a KEY, never an 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 |
296
297
 
297
298
  **A `task` node — and an `ask` — must have an edge that can be taken when it succeeds.** A node whose only edge is
298
299
  `{ when: { predicate: 'p' }, to: 'x' }` is refused at construction — it is the mistake the format most invites, and
@@ -522,7 +523,7 @@ that passed it through.
522
523
  | 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 |
523
524
  | 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 |
524
525
  | `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
525
- | 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) |
526
+ | on a resume | the same value, from the stored header: nothing new is written to the log, the header or the checkpoint (`CHECKPOINT_FORMAT` does not move for it — it is 2 since 0.12.0, for the join's keys, section 5) |
526
527
  | 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 |
527
528
  | 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) |
528
529
  | what it costs | ONE copy of the whole payload per RUN, from 0.10.0 — however many instances of a `map` read it. *(Until 0.10.0 one copy per key per read: inside a `map`, once per instance — the cliff above at its largest.)* On an ASK the copy is also written into the run's LOG: the `asked` element keeps what reached the ask (section 7) |
@@ -617,7 +618,7 @@ passed it through.
617
618
  | always there | no `?` and no array form: `["$item"]` and `"$item?"` are `FD-V26`, each naming the bare form |
618
619
  | 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 |
619
620
  | 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`) |
620
- | 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) |
621
+ | 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` does not move for it — it is 2 since 0.12.0, for the join's keys, section 5) |
621
622
  | 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`* |
622
623
  | 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) |
623
624
  | 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 |
@@ -811,8 +812,13 @@ included), a `retryPolicy`, a `transform` and an `answerSchema` — each key wit
811
812
  validator, or to an agent writing a flow, BEFORE the findings. It is BUILT from the validator's own key sets, so its
812
813
  keys cannot drift from them (the reference tokens and the operator partition are written beside them and held to the
813
814
  validator by its tests); it is a new plain object on every call, yours to change. A join's `of` and a fanout's branch
814
- entries are held to the id charset only where they are strings — the validator does no more today
815
- ([Q774](doc/questions/q774.md)).
815
+ entries are held to the id charset, a non-string refused — as `validateDefinition` refuses one under `FD-V21` **since
816
+ 0.12.0** (`spec/flow-definition.md` v0.52, [Q774](doc/questions/q774.md)). **That is a change in what is accepted**:
817
+ `of: null`, or `branches: ["b", "c", 5]`, passed 0.11.0's validator with no finding when nothing else in the flow
818
+ tripped over it, and is refused now. **And the other way, since 0.12.0** (`spec/flow-definition.md` v0.53,
819
+ [ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)):
820
+ a gate's key and a comparison's `path` may carry a bracket index — `files[0].series`, `outcome.result.files[0].series` — so both patterns accept it — and
821
+ `KEY_PATH_RE`, the gate key's, is the exported constant whose `source` moved. Only more is accepted.
816
822
 
817
823
  | | |
818
824
  |---|---|
@@ -938,7 +944,7 @@ if (refuse({ all: [{ any: [{ path: 'outcome.result.a', op: 'eq', value: 1 }] }]
938
944
 
939
945
  | | |
940
946
  |---|---|
941
- | `path` | a key path over the context, **rooted at `outcome` or `needs`** — `outcome.result.x`, `outcome.taskId`, `needs.earlier.flag`. No array index, no wildcard, no expression |
947
+ | `path` | a key path over the context, **rooted at `outcome` or `needs`** — `outcome.result.x`, `outcome.taskId`, `needs.earlier.flag`, and an array element by a bracket index after a key, `outcome.result.items[0].score` (never on the root). No wildcard, no expression |
942
948
  | `op` | one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `startsWith`, and **nothing else** |
943
949
  | `value` | a scalar: `null`, a boolean, a finite number or a string |
944
950
  | `default` | optional — what is compared when the path does not resolve **or resolves to `null`**, which is exactly `??`. Without it an absent path is **no match**, and a resolved `null` is compared as the scalar it is |
@@ -1225,10 +1231,12 @@ is not a supported choice. See
1225
1231
 
1226
1232
  ### What a join carries downstream
1227
1233
 
1228
- **The arrived branches' results, keyed by branch path.** A satisfied join dispatches its successor
1229
- with an object whose keys are the `branchPath`s of the branches that **reached** it, and whose values
1230
- are what each of those branches carried in — so a fan-out's results are not thrown away, and the
1231
- value is the same whatever order the branches finished in.
1234
+ **The arrived branches' results, each under the branch's OWN key.** A satisfied join dispatches its
1235
+ successor with an object holding one entry per branch that **reached** it — keyed by the branch's
1236
+ **entry node id** for a `fanout` and by the **item index** for a `map`, one segment at every depth —
1237
+ whose values are what each of those branches carried in. So a fan-out's results are not thrown away,
1238
+ a key path reaches into any depth (`input.prep.1`), and the value is the same whatever order the
1239
+ branches finished in.
1232
1240
 
1233
1241
  ```ts run
1234
1242
  import { createRuntime } from 'fyflow-orchestration/scheduler';
@@ -1250,7 +1258,7 @@ const flow: FlowDefinition = {
1250
1258
  flowId: 'collect', version: 1, entry: 'split',
1251
1259
  nodes: [
1252
1260
  { id: 'split', kind: 'fanout', branches: ['scoreA', 'scoreB'] },
1253
- // `scoreA` is branch path "0" and is made SLOW on purpose, so "1" settles first.
1261
+ // `scoreA` is the FIRST entry and is made SLOW on purpose, so `scoreB` settles first.
1254
1262
  { id: 'scoreA', kind: 'task', type: 'Scorer', settings: { score: 0.9, ms: 40 }, edges: [{ to: 'collect' }] },
1255
1263
  { id: 'scoreB', kind: 'task', type: 'Scorer', settings: { score: 0.4 }, edges: [{ to: 'collect' }] },
1256
1264
  { id: 'collect', kind: 'join', of: 'split', edges: [{ to: 'publish' }] },
@@ -1269,7 +1277,7 @@ const inputs: FoldInput[] = [];
1269
1277
  const state = await runtime.start(flow, {}, { onFoldInput: (input) => { inputs.push(input); } }).done;
1270
1278
  await runtime.shutdown();
1271
1279
 
1272
- // **The order is READ, not assumed.** The delay above ASKS for branch "1" to settle first; this is
1280
+ // **The order is READ, not assumed.** The delay above ASKS for `scoreB` to settle first; this is
1273
1281
  // what the run actually did, and the claim below is about the collection being independent of it.
1274
1282
  const settledOrder = inputs.flatMap((i) =>
1275
1283
  (i.kind === 'outcome' && /:score[AB]:/.test(i.record.taskId) ? [i.record.taskId.split(':')[1]] : []));
@@ -1278,13 +1286,14 @@ console.log(settledOrder.join(' then ')); // 'scoreB then scoreA'
1278
1286
  // `publish` is the join's successor, so what it received IS the collection — and `tag` returns what
1279
1287
  // it was handed with its own label added, so the collection is readable in the result.
1280
1288
  const published = Object.values(state.nodes).find((n) => n.nodeId === 'publish')?.result;
1281
- console.log(JSON.stringify(published)); // {"0":{"score":0.9},"1":{"score":0.4},"publish":true}
1289
+ console.log(JSON.stringify(published)); // {"scoreA":{"score":0.9},"scoreB":{"score":0.4},"publish":true}
1282
1290
 
1283
- const want = '{"0":{"score":0.9},"1":{"score":0.4},"publish":true}';
1291
+ const want = '{"scoreA":{"score":0.9},"scoreB":{"score":0.4},"publish":true}';
1284
1292
  if (JSON.stringify(published) !== want) throw new Error(`the join carried ${JSON.stringify(published)}`);
1285
- // **B settled FIRST and is still under key "1".** The collection is keyed by BRANCH PATH, not by
1286
- // arrival order, which is the whole point — and the two assertions are a pair: the first says which
1287
- // order the run took, the second says the record did not follow it.
1293
+ // **B settled FIRST and is still under its own key, listed SECOND.** The collection is keyed by each
1294
+ // branch's ENTRY ID and inserted in `branches` order, not in arrival order, which is the whole point —
1295
+ // and the two assertions are a pair: the first says which order the run took, the second says the
1296
+ // record did not follow it.
1288
1297
  if (settledOrder.join() !== 'scoreB,scoreA') throw new Error(`settled ${settledOrder.join()} — the arrangement did not hold, so the claim below is untested`);
1289
1298
  ```
1290
1299
 
@@ -1297,20 +1306,32 @@ if (settledOrder.join() !== 'scoreB,scoreA') throw new Error(`settled ${settledO
1297
1306
  2. **`{}` is a real answer, not an error.** Under `liveToken` a join fires once its region has no live
1298
1307
  branches, and every branch reaching an `end` satisfies that with nothing arrived. If that should be
1299
1308
  a failure instead, pass `liveTokenRequiringArrival` — see above.
1300
- 3. **The keys are strings, in ascending branch-path order**, compared segment by segment as numbers —
1301
- so `"0"`, `"1"`, `"2"`, `"10"` at the top level, and `"1.0"`, `"1.1"`, `"1.10"` inside branch 1.
1302
- A nested region's inner join carries its own object, which becomes the value of the outer branch it
1303
- sits in. **Every key of one collection has the same number of segments**, because they are the
1304
- branch paths of one region. *(An earlier version of this bullet illustrated the order as `"1"`,
1305
- `"1.0"`, `"2"`, `"10"` — a mix of depths that no single collection can hold, and one that
1306
- `Object.keys` would not even return in that order, because JavaScript re-orders integer-like keys
1307
- ahead of string ones. W34's conformity reader.)*
1309
+ 3. **Every key is ONE segment, at every depth — the branch's own key.** A `fanout`'s branch is keyed by
1310
+ its entry node id (`FD-V9` keeps them distinct), a `map`'s instance by its item index — so a
1311
+ fan-out whose first branch holds a map gives `{"prep":{"0":1,"1":2},"side":{"xs":[1,2]}}`, never
1312
+ `"0.1"` under `"0"`. A nested region's inner join carries its own object, which becomes the value of
1313
+ the outer branch it sits in. The full branch path is not lost: it is the region's own path (`regionPath`) + `.` + the
1314
+ position — the position alone at the top level, where `regionPath` is `-` — on every `branch.*` event, task id and
1315
+ `inputOf` path, which are unchanged.
1316
+ 4. **The order is the object's own.** Entries are inserted in `branches` order for a fan-out and in
1317
+ ascending item index for a map, and **JavaScript lists integer-like keys first, ascending** — so a
1318
+ fan-out whose entries are `"b"` and `"2"` lists `"2"` first. Deterministic whatever order the
1319
+ branches finished in; no node id is refused for it. Each entry is an own property, so even an entry
1320
+ id `__proto__` is a key.
1321
+ 5. **BREAKING in 0.12.0, and a run in flight across the upgrade cannot be resumed.** Until 0.11.0 the
1322
+ keys were full branch paths (`{"0":{"0.0":1,"0.1":2},"1":{"xs":[1,2]}}` for the flow above) and a
1323
+ fan-out's were POSITIONS; a flow reading `"0.1"` or `"0"` reads nothing now. A collection reaches a
1324
+ checkpoint (an ask's kept input, a gate's decision, a worker's result), so **`CHECKPOINT_FORMAT` is
1325
+ 2** and a checkpoint 0.11.0 wrote — join or none — is refused `ResumeRefused('format')` by
1326
+ `resume()`, `runtime.resume` and `inputOf`; a 0.11.0 driver refuses a 0.12.0 checkpoint the same
1327
+ way. There is no migration: drain or abandon those runs first
1328
+ ([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md)).
1308
1329
 
1309
1330
  *(This section read **"`null`, currently … this is [Q36](doc/questions/q36.md) and it is open"**
1310
1331
  until [ADR 0037](doc/decisions/0037-what-a-branch-receives-is-a-property-of-the-fan-out-and-what-a-join-carries-is-a-property-of-the-region.md).
1311
1332
  The argument for the `null` was real and is unchanged — which branch finishes last is genuinely
1312
1333
  non-deterministic — and what answered it is that carrying **all** the arrivals keyed by branch is not
1313
- picking one. `spec/join-policy.md` section 4, `JP-R12`.)*
1334
+ picking one. `spec/join-policy.md` section 4, `JP-R12`; its keys are each branch's own since 0.12.0.)*
1314
1335
 
1315
1336
  ### The same on the RUNTIME — a decision, a fan-out and a join on real workers
1316
1337
 
@@ -1345,7 +1366,7 @@ const isLowConfidence: Predicate = (ctx) => {
1345
1366
 
1346
1367
  // THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest` goes to `review` when the predicate holds,
1347
1368
  // otherwise to `split`. `split` runs two branches concurrently; `collect` waits for both and hands
1348
- // `publish` every branch's result, keyed by branch path.
1369
+ // `publish` every branch's result, each under its own entry id.
1349
1370
  const flow: FlowDefinition = {
1350
1371
  flowId: 'decide', version: 1, entry: 'ingest',
1351
1372
  nodes: [
@@ -1373,13 +1394,13 @@ const ran = (s: RunState) => Object.values(s.nodes).map((n) => n.nodeId).sort();
1373
1394
  const resultOf = (s: RunState, id: string) => Object.values(s.nodes).find((n) => n.nodeId === id)?.result;
1374
1395
  console.log(ran(high)); // [ 'classify', 'enrich', 'ingest', 'publish' ]
1375
1396
  console.log(ran(low)); // [ 'ingest', 'review' ]
1376
- console.log(resultOf(high, 'publish')); // { '0': { confidence: 0.9, ingest: true, enrich: true }, '1': { …, classify: true }, publish: true }
1397
+ console.log(resultOf(high, 'publish')); // { enrich: { confidence: 0.9, ingest: true, enrich: true }, classify: { …, classify: true }, publish: true }
1377
1398
 
1378
1399
  // Verified by the docs gate:
1379
1400
  if (high.outcome !== 'completed' || low.outcome !== 'completed') throw new Error(`outcomes ${high.outcome} / ${low.outcome}`);
1380
1401
  if (ran(high).join() !== 'classify,enrich,ingest,publish') throw new Error(`high ran ${ran(high).join()}`);
1381
1402
  if (ran(low).join() !== 'ingest,review') throw new Error(`low ran ${ran(low).join()}`);
1382
- const want = '{"0":{"confidence":0.9,"ingest":true,"enrich":true},"1":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
1403
+ const want = '{"enrich":{"confidence":0.9,"ingest":true,"enrich":true},"classify":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
1383
1404
  if (JSON.stringify(resultOf(high, 'publish')) !== want) throw new Error(`publish received ${JSON.stringify(resultOf(high, 'publish'))}`);
1384
1405
  ```
1385
1406
 
@@ -1630,9 +1651,10 @@ keeps waiting, and the run is not stuck.
1630
1651
  the element's `observedAt` is the driver's `now` — the clock an outcome record's `observedAt` reads — and the node's
1631
1652
  entry carries it as **`askedAt`**, kept after the answer as `input` is. **A resume KEEPS the stored time**: the replay
1632
1653
  regenerates the element and takes its stored `observedAt`, so a wait measured across a resume does not restart; past
1633
- the stored tail it is the resumed driver's `now`. An `asked` element written before 0.11.0 has none, and its run
1634
- resumes with none. **A driver older than 0.11.0 refuses a newer checkpoint `diverged` at its first ask** — resume a
1635
- run on a driver at least as new as the one that wrote it.
1654
+ the stored tail it is the resumed driver's `now`. *(An `asked` element written before 0.11.0 has none; since 0.12.0
1655
+ such a checkpoint is refused `format` before its events are read — section 5, `CHECKPOINT_FORMAT` 2.)* **A driver
1656
+ older than 0.11.0 refused a 0.11.0 checkpoint `diverged` at its first ask**; any driver before 0.12.0 refuses a 0.12.0
1657
+ checkpoint `format` before replaying anything — resume a run on a driver of the format that wrote it.
1636
1658
  - **The answer is `driver.resolve(taskId, …)`** — the same call as below. `'done'` with `result` the answer, held to the
1637
1659
  ask's `answerSchema` (refused as `BoundaryRefused` with NOTHING appended and the ask still parked — answer again;
1638
1660
  `validateBoundaries: false` turns it off); `'failed'` with an `error` is the person DECLINING, held to nothing.
@@ -2882,7 +2904,9 @@ After a refusal the run is **halted**: nothing routes for it again, and its stat
2882
2904
  and FOUR surfaces are renamed — the `RunOutcome` `stopped` is `halted`, `RunState.stoppedBy` is `haltedBy`, the fold
2883
2905
  input `{ kind: 'stopped', reason }` is `{ kind: 'halted', reason }`, and `ResumeRefusalReason`'s `stopped` is
2884
2906
  `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`:
2885
- `fold` folds it so, and a resume refuses it `halted` at its position.
2907
+ `fold` folds it so, and a resume refuses it `halted` at its position — but a 0.7.0 checkpoint is format 1, so since
2908
+ 0.12.0 a resume refuses it `format` first (section 5); the `halted` read is reached only by such an element under a
2909
+ format-2 header, which no driver writes.
2886
2910
 
2887
2911
  ---
2888
2912
 
@@ -4075,7 +4099,7 @@ driver.start({ items: [1, 2, 3] });
4075
4099
 
4076
4100
  **What an expression sees, and what it may not.** `$` is the node's input — the previous node's
4077
4101
  result, the object `needs` described, a map instance's own item, or a join's collection keyed by
4078
- branch path — and the node's effective `settings` are bound as `$settings`, so one expression is
4102
+ each branch's own key — and the node's effective `settings` are bound as `$settings`, so one expression is
4079
4103
  parameterised per node. **Five names are unbound, by name, on every compiled expression**: `$now`,
4080
4104
  `$millis`, `$random` — the three every adapter must refuse, because a transform is a function of what
4081
4105
  the run produced and run state is a fold of the log — **`$shuffle`**, JSONata's own fourth, which
@@ -4130,10 +4154,10 @@ if (!publish('$sum([*.score])', { ...registry, languages: {} }).some((f) => f.ru
4130
4154
  // The envelope worker — the class the bootstrap helper puts around your worker — with the adapter's worker half by URL.
4131
4155
  const w = new EnvelopeWorker({ worker: echo.url, languages: { jsonata: jsonata.worker } });
4132
4156
  await w.setup();
4133
- const record = { '0': { score: 40 }, '1': { score: 20 } }; // JP-R12's collection, two branches
4157
+ const record = { scoreA: { score: 40 }, scoreB: { score: 20 } }; // JP-R12's collection: `split`'s two entry ids
4134
4158
  const total = await w.run({ settings: { factor: 2 }, input: record, transform: { lang: 'jsonata', expr: '$sum([*.score]) * $settings.factor' } });
4135
4159
  if (total !== 120) throw new Error(`the worker received ${JSON.stringify(total)} — expected the reduction, 120`);
4136
- const one = await w.run({ settings: {}, input: { '0': { score: 7 } }, transform: { lang: 'jsonata', expr: '$sum([*.score])' } });
4160
+ const one = await w.run({ settings: {}, input: { scoreA: { score: 7 } }, transform: { lang: 'jsonata', expr: '$sum([*.score])' } });
4137
4161
  if (one !== 7) throw new Error('arity one, the same expression: 7');
4138
4162
  const none = await w.run({ settings: {}, input: {}, transform: { lang: 'jsonata', expr: '$sum([*.score])' } });
4139
4163
  if (none !== 0) throw new Error('a join nothing reached: 0, because the reduction is over the ARRAY form');
@@ -4571,7 +4595,7 @@ Three things follow, and they are the whole of what you need:
4571
4595
  |---|---|
4572
4596
  | **`RunState.regions` is TWO-LEVEL** | `regions[openerId][openerPath]`. The opener's id is unchanged in meaning and in value; the second key is `-` for a region in no region, and inside a `map` region it is that map's item index |
4573
4597
  | **every `RegionEvent` carries `regionPath` beside `regionId`** | so an event says WHICH instance it is about. The same for `UnsatisfiableJoin` |
4574
- | **a join's collection is per INSTANCE** | `JP-R12` keys it by branch path, and a branch inside an instance carries that instance's path as its own prefix — so two instances of one opener hold DISJOINT key sets and cannot collide |
4598
+ | **a join's collection is per INSTANCE** | each instance of one opener holds its OWN collection, so two instances cannot collide — even though, since 0.12.0, their keys are each branch's own key (`JP-R12`) and two instances may hold the SAME key set ([ADR 0138](doc/decisions/0138-a-join-hands-on-each-branch-under-its-own-key-and-a-checkpoint-of-the-old-keying-is-refused-by-format.md): until 0.11.0 the keys were branch paths, carrying the instance's path as a prefix) |
4575
4599
 
4576
4600
  **Both arities come from data, at both levels, and neither is in the document.** That is what
4577
4601
  separates a `map` from a `fanout`: `FD-V9` requires a fan-out's branch entries to be written down, so
@@ -4657,11 +4681,12 @@ if (!arrivals.includes('perLine@0 <- 0.0') || !arrivals.includes('perLine@1 <- 1
4657
4681
  }
4658
4682
 
4659
4683
  // WHAT THE OUTER JOIN CARRIES IS TWO LEVELS DEEP. `JP-R12`: one entry per branch that arrived, keyed
4660
- // by branch path — and each of the outer entries IS that instance's own collection.
4684
+ // by its OWN key — an item index at both levels — and each of the outer entries IS that instance's own
4685
+ // collection. So `report.0.1` is order 0's second line: the key path a gate or an `if` reads.
4661
4686
  const report = Object.values(state.nodes).find((n) => n.nodeId === 'report')?.result as Record<string, Record<string, { sku: string }>>;
4662
4687
  console.log(Object.keys(report).filter((k) => k !== 'reported').sort()); // [ '0', '1' ]
4663
- console.log(report['0']!['0.1']!.sku); // ab-1
4664
- if (report['0']!['0.1']!.sku !== 'ab-1' || report['1']!['1.0']!.sku !== 'c-0') {
4688
+ console.log(report['0']!['1']!.sku); // ab-1
4689
+ if (report['0']!['1']!.sku !== 'ab-1' || report['1']!['0']!.sku !== 'c-0') {
4665
4690
  throw new Error(`the outer collection's values are the inner collections: ${JSON.stringify(report)}`);
4666
4691
  }
4667
4692
  ```
@@ -4794,7 +4819,7 @@ console.log(state.outcome); // completed
4794
4819
  if (state.outcome !== 'completed') throw new Error(`expected completed, got ${state.outcome}`);
4795
4820
  // The same answer a run nobody killed gives (section 17's):
4796
4821
  const report = Object.values(state.nodes).find((n) => n.nodeId === 'report')?.result as Record<string, Record<string, { sku: string }>>;
4797
- if (report['0']!['0.1']!.sku !== 'ab-1' || report['1']!['1.0']!.sku !== 'c-0') {
4822
+ if (report['0']!['1']!.sku !== 'ab-1' || report['1']!['0']!.sku !== 'c-0') {
4798
4823
  throw new Error(`the resumed run reaches the same report: ${JSON.stringify(report)}`);
4799
4824
  }
4800
4825
 
@@ -4931,11 +4956,13 @@ if (JSON.stringify(store.header) !== JSON.stringify(driver.checkpoint().header))
4931
4956
  ([Q374](doc/questions/q374.md)) — and so is a stored `attempted` event's number, which a re-run task is handed as
4932
4957
  the attempts it SPENT (one naming a task the replay never dispatched, carrying no positive integer, or not RISING over
4933
4958
  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.
4934
- - **`ResumeRefused` names its `reason`**: `format` (not a checkpoint this version writes), `run` (the
4959
+ - **`ResumeRefused` names its `reason`**: `format` (not a checkpoint this version writes — **every checkpoint 0.11.0
4960
+ or earlier wrote is format 1 and refused here**: `CHECKPOINT_FORMAT` is 2 since 0.12.0, section 5), `run` (the
4935
4961
  header's run is not the driver's), `flow` (another flow id or `version` — there is no migration),
4936
4962
  `halted`, `cancelled` and `diverged` — the last three with the `index` of the position. **A HALTED
4937
- run is not resumable**: the refusal that halted it would halt it again — and a 0.7.0 checkpoint whose
4938
- halt is stored under the old kind, `stopped`, is refused `halted` at that element's position (ADR 0102). **Nor is a CANCELLED one**
4963
+ run is not resumable**: the refusal that halted it would halt it again — and a halt stored under 0.7.0's kind,
4964
+ `stopped`, is refused `halted` at that element's position (ADR 0102; a 0.7.0 checkpoint itself is format 1, refused
4965
+ `format` first). **Nor is a CANCELLED one**
4939
4966
  (section 6): a cancel is its owner's act, and a resume would hand the scheduler again what the owner
4940
4967
  stopped — `index` is the first cancelled settlement or the cancel request, whichever is first. A completed or `stuck` run resumes to itself and
4941
4968
  dispatches nothing. **A STOPPED run is the one terminal run a resume TAKES** (below): its `interrupted` event is replayed, and `resumed` ends it.
@@ -5161,7 +5188,7 @@ rest, while every file gets a thumbnail* — had nowhere to keep that memory.
5161
5188
 
5162
5189
  | | |
5163
5190
  |---|---|
5164
- | **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) |
5191
+ | **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, each key optionally followed by bracket indexes into an ARRAY (`files[0].series`, `m[0][2]`; `[n]` with no leading zero, at most 999,999,999, never leading the path — [ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)); no quoted key, no wildcard, no expression. **`[1]` and `.1` are two paths**: `xs[1]` is element 1 of a real array and nothing over an object, `xs.1` an object's own key `"1"` and nothing over an array. 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) |
5165
5192
  | **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`) |
5166
5193
  | **it computes nothing** | the branch leaves on the chosen route with EXACTLY the value that reached the gate |
5167
5194
  | **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 |
@@ -5252,12 +5279,13 @@ for (const d of firsts) {
5252
5279
  }
5253
5280
 
5254
5281
  // A DROP IS AN `end`, so it is a path end like any other — and the thumbnail ran for EVERY file: each file's
5255
- // `perFile` record holds its thumbnail, and nothing from the gate road, which ended at an `end` in every branch.
5282
+ // `perFile` record holds its thumbnail under `beside`'s entry id `thumbnail`, and nothing from the gate road,
5283
+ // which ended at an `end` in every branch.
5256
5284
  console.log(state.pathEnds.map((p) => p.at).sort());
5257
5285
  // [ 'done', 'dropDuplicate', 'dropDuplicate', 'dropUnidentified', 'seriesReady', 'seriesReady' ]
5258
5286
  const published = Object.values(state.nodes).find((n) => n.nodeId === 'publish')!.result as
5259
5287
  { joinPayload: Record<string, Record<string, { thumbnail?: string }>> };
5260
- const thumbs = Object.entries(published.joinPayload).map(([i, perFile]) => perFile[`${i}.0`]?.thumbnail);
5288
+ const thumbs = Object.values(published.joinPayload).map((perFile) => perFile['thumbnail']?.thumbnail);
5261
5289
  if (thumbs.length !== files.length || thumbs.some((t) => t === undefined)) throw new Error(`a thumbnail per file: ${thumbs.join()}`);
5262
5290
 
5263
5291
  // A GATE TYPE OF YOUR OWN is CODE, registered by name the way `distinct` is written. A route its `fn` returns that
@@ -5466,7 +5494,7 @@ its three routes, so the third is the control sitting in the same report.
5466
5494
  | the author writes | the check says |
5467
5495
  |---|---|
5468
5496
  | a key path the producer FORBIDS at any step | **`no`** on every arc leaving by a key-resolved route, naming the gate, the key, the STEP it stopped on, the word the schema itself uses, and the route |
5469
- | a key path the producer describes | those arcs carry `required` along the path, so a consumer requiring it **`fits`** |
5497
+ | a key path the producer describes | those arcs carry `required` along the path, so a consumer requiring it **`fits`** — and through a bracket index `[n]`, `minItems: n + 1` on that array and nothing about any element past it, since `items` speaks for every element ([ADR 0137](doc/decisions/0137-a-key-path-indexes-an-array-by-a-bracket-suffix-and-a-plain-segment-keeps-its-meaning.md)) |
5470
5498
  | the same arc, with the type declaring no guarantee | **`no`** again — *consumer requires "owner", producer does not guarantee it* |
5471
5499
  | an arc leaving by a route the type does NOT call key-resolved | its own verdict, unchanged — the refusal is the ROUTE's and not the gate's |
5472
5500
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ worker returned, routing can **fan out** into parallel branches, and a **join**
17
17
  The scheduler runs tasks; this library decides **which task runs next**. `fyflow-scheduler` has no
18
18
  dependency graph of its own, so sequence, decision, fan-out and join live here.
19
19
 
20
- Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.11.0`.
20
+ Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.12.0`.
21
21
 
22
22
  ## Install
23
23
 
@@ -93,7 +93,7 @@ if (JSON.stringify(shout?.result) !== '{"text":"hello world !"}') throw new Erro
93
93
 
94
94
  A **decision** is an edge with a `when` naming a predicate: code you supply, under a name the
95
95
  registry declares. A **fan-out** opens branches that run concurrently; a **join** waits for them and
96
- hands the next node every branch's result, keyed by branch path. Below, two runs at high and low
96
+ hands the next node every branch's result, each under its own entry id. Below, two runs at high and low
97
97
  confidence take different roads. The worker is
98
98
  [examples/getting-started/tag.ts](examples/getting-started/tag.ts).
99
99
 
@@ -116,7 +116,7 @@ const isLowConfidence: Predicate = (ctx) => {
116
116
 
117
117
  // THE FLOW — a DECISION, a FAN-OUT, a JOIN. `ingest` goes to `review` when the predicate holds,
118
118
  // otherwise to `split`. `split` runs two branches concurrently; `collect` waits for both and hands
119
- // `publish` every branch's result, keyed by branch path.
119
+ // `publish` every branch's result, each under its own entry id.
120
120
  const flow: FlowDefinition = {
121
121
  flowId: 'decide', version: 1, entry: 'ingest',
122
122
  nodes: [
@@ -144,13 +144,13 @@ const ran = (s: RunState) => Object.values(s.nodes).map((n) => n.nodeId).sort();
144
144
  const resultOf = (s: RunState, id: string) => Object.values(s.nodes).find((n) => n.nodeId === id)?.result;
145
145
  console.log(ran(high)); // [ 'classify', 'enrich', 'ingest', 'publish' ]
146
146
  console.log(ran(low)); // [ 'ingest', 'review' ]
147
- console.log(resultOf(high, 'publish')); // { '0': { confidence: 0.9, ingest: true, enrich: true }, '1': { …, classify: true }, publish: true }
147
+ console.log(resultOf(high, 'publish')); // { enrich: { confidence: 0.9, ingest: true, enrich: true }, classify: { …, classify: true }, publish: true }
148
148
 
149
149
  // Verified by the docs gate:
150
150
  if (high.outcome !== 'completed' || low.outcome !== 'completed') throw new Error(`outcomes ${high.outcome} / ${low.outcome}`);
151
151
  if (ran(high).join() !== 'classify,enrich,ingest,publish') throw new Error(`high ran ${ran(high).join()}`);
152
152
  if (ran(low).join() !== 'ingest,review') throw new Error(`low ran ${ran(low).join()}`);
153
- const want = '{"0":{"confidence":0.9,"ingest":true,"enrich":true},"1":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
153
+ const want = '{"enrich":{"confidence":0.9,"ingest":true,"enrich":true},"classify":{"confidence":0.9,"ingest":true,"classify":true},"publish":true}';
154
154
  if (JSON.stringify(resultOf(high, 'publish')) !== want) throw new Error(`publish received ${JSON.stringify(resultOf(high, 'publish'))}`);
155
155
  ```
156
156