fyflow-orchestration 0.9.1 → 0.11.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
@@ -347,6 +347,23 @@ that object and nothing else — there is no wrapper and no reserved key.
347
347
  | `"someNode"` | that node's result, **on your own branch** |
348
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 |
349
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* |
350
+ | **`!error` after the node id, on any of those** — `"someNode!error"`, `["someNode!error?"]`, … | that node's **ERROR** instead of its result: the string its failure was reduced to, `null` where it succeeded (since 0.11.0, below) |
351
+
352
+ **What a reference hands you is ONE shared, frozen copy — read it, never write it** — from 0.10.0 ([ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md),
353
+ `spec/flow-definition.md` v0.47). The value under a `"someNode"` or `["someNode"]` key (either with `?`) is ONE deep copy
354
+ of that output, made the first time anything reads it and handed to EVERY reader of it — every instance of a `map`, every
355
+ node after it, and every edge predicate (`ctx.needs`, section 4) — and a `"$payload"` key is one such copy of the run's
356
+ payload for the whole run. The object `needs` composes is still yours, a `["someNode"]` array is a fresh one per read, and
357
+ `"."` and `"$item"` are still your own copies. **The copy is FROZEN where JavaScript can freeze**: plain objects and arrays
358
+ at every depth, so writing into one throws `TypeError` in strict code — an **inline** worker that does so fails that
359
+ attempt (your `retryPolicy` decides what follows), and a **predicate** in strict code that does so stops the run (in
360
+ sloppy code the write is silently dropped). A threaded worker
361
+ writes into its own clone, as before. **What freezing cannot protect is yours to leave alone**: a `Map`, `Set`, `Date`
362
+ or `ArrayBuffer` in a result is frozen only shallowly (`set`, `add`, `setTime` and a write through a view still work, and
363
+ a `Map`'s keys and values, a `Set`'s members and an `Error`'s `cause` are not frozen), and a typed array, `DataView` or
364
+ `RegExp` is not frozen at all (a frozen `RegExp` could not run its own `test` or `exec`) — an inline worker writing into one changes what every other reader of
365
+ that output sees. Copy before you change anything a reference handed you. *(Until 0.10.0 every read was its own deep
366
+ copy, so such a write landed on yours alone — and a reference to a large result inside a `map` cost one copy per item.)*
350
367
 
351
368
  **Which of the two aimed forms is legal is decided by the graph and refused at construction**
352
369
  (`FD-V28`), so you are told which to write rather than discovering the shape at run time. The message
@@ -458,14 +475,15 @@ in W50* for exactly as long as that was true.)*
458
475
  And a reference names a **node**, never a path inside its value: `needs: { run: 'ingest' }` gives you
459
476
  the whole of `ingest`'s result and your worker reads `.config` off it.
460
477
 
461
- **And inside a `map`, what it costs is what you point it at.** Each instance gets its OWN COPY of
462
- each key, so a reference to a large result is copied once per item. Measured at a map arity of 1000
463
- over a ~126 KiB payload, **one case per process**: no `needs` **5.5 ms**, `{item: '.'}` **7.7 ms**,
464
- one more key naming a genuinely small node **8.7 ms**, and the same key naming the large one
465
- **613.6 ms** — **71x**, and the `?` suffix costs nothing (**617.4 ms**). The mechanism makes one copy
466
- per instance and the cost is linear in the size of each, so the product is what you pay. That is also
467
- why a reference names a whole node and never a path inside it — a path language would hide this
468
- choice. *(These five figures are compared to `spec/flow-definition.md` section 3's own table by
478
+ **And inside a `map`, what it costs is what you point it at.** From 0.10.0 that is ONE copy of it, whatever the
479
+ arity (above). Until 0.10.0 each instance got its OWN COPY of each key, so a reference to a large result was copied
480
+ once per item, every copy live at once: fyflow-app's *fetch a list, then map over it* at 4,000 items cost the driver
481
+ alone 15.5 s and 1.4 GB in 0.9.1, and 0.09 s and 15 MB from 0.10.0 (node 24). Measured at a map arity of 1000 over a
482
+ ~126 KiB result, the driver alone, **one case per process**: no `needs` **24.4 ms**, `{item: '.'}` **25.3 ms**, one
483
+ more key naming a genuinely small node **28.0 ms**, and the same key naming the large one **30.5 ms** — **1.1x**, where
484
+ it was 17x in 0.9.1 on the same machine — and the `?` suffix costs nothing (**28.3 ms**). The mechanism makes one copy
485
+ per output read, whoever reads it. A reference still names a whole node and never a path inside it — a path
486
+ language would make *the small part of the big thing* a selector's business rather than the document's. *(These five figures are compared to `spec/flow-definition.md` section 3's own table by
469
487
  `check-docs`' `X6`, both ways — three documents carried them and nothing compared any two, which is
470
488
  `doc/workflow.md` section 5 rule 8 obligation (e)'s third shape.)*
471
489
 
@@ -475,7 +493,9 @@ including the flow it exists for, so it restates the definition rather than diag
475
493
  a report channel exists (`onUnmintedTask`'s shape) and is declined because *this reference is resolved
476
494
  once per item* is a fact about your document rather than about your run. **Measuring is not the
477
495
  obstacle** — a size measurement is about 0.03% of what it would warn about at arity 1000.
478
- `spec/flow-definition.md` section 3 carries the whole verdict and the measurements.
496
+ `spec/flow-definition.md` section 3 carries the whole verdict and the measurements. **From 0.10.0 there is nothing
497
+ left to warn about**: the per-item copy that decision was about is gone ([Q186](doc/questions/q186.md), resolved by
498
+ [ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md)), and the decision stands as written.
479
499
 
480
500
  **Finding what a reference names costs its ANSWER** — from 0.9.1
481
501
  ([ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md), `spec/flow-definition.md` v0.46). A branch task
@@ -484,8 +504,8 @@ predecessor — and the read takes that one output, however many instances the m
484
504
  by the branch-path prefix its readers share. In 0.9.0 every read searched every output of the node it names, so such a
485
505
  map cost O(items²) (the driver alone at 8,000 items: 22 s in 0.9.0, 0.22 s from 0.9.1, node 24). **A read never costs
486
506
  less than its answer**: `["n"]` from a reader outside every region `n` is in — a SIBLING map's reader — is handed every
487
- output of `n`, so two maps of `n` and `m` instances compose `n·m` entries. That is the answer's price, the cliff above by
488
- another road, and the remedy is the same.
507
+ output of `n`, so two maps of `n` and `m` instances compose `n·m` entries — from 0.10.0 `m` fresh arrays of the same `n`
508
+ shared copies, so the price is the arrays, not `n·m` clones. That is the answer's price, and the remedy is the cliff's.
489
509
 
490
510
  ### `"$payload"` — what reached the RUN, from any node
491
511
 
@@ -498,14 +518,14 @@ that passed it through.
498
518
 
499
519
  | | |
500
520
  |---|---|
501
- | 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 |
521
+ | 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. From 0.10.0 every `"$payload"` key of the run, at every node and edge, is ONE deep-frozen copy of it, made at the first read — a worker or a predicate writing into it throws `TypeError` and reaches nothing (above). *(Until 0.10.0 each key was its own deep copy, per read.)* |
502
522
  | 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 |
503
523
  | 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 |
504
524
  | `"$item"` | the prefix's second reference, the item of the map instance the reader is in — the next subsection |
505
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) |
506
526
  | 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 |
507
527
  | 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) |
508
- | 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) |
528
+ | 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) |
509
529
 
510
530
  **`givenVerdict` and `tryTransform(…, { input })` compose nothing** (section 15): the value you give them IS what the node
511
531
  receives, so a node declaring `needs: { run: "$payload" }` is given `{ run: <the payload> }` — composing it is yours, as
@@ -524,7 +544,7 @@ const scheduler = {
524
544
  addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
525
545
  addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
526
546
  };
527
- const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
547
+ const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
528
548
 
529
549
  const flow: FlowDefinition = {
530
550
  flowId: 'pay-invoice', version: 1, entry: 'approve',
@@ -555,9 +575,9 @@ driver.start(invoice);
555
575
  invoice.amount = 1; // yours to change: the run holds its own copy
556
576
 
557
577
  driver.resolve('r1:approve:-:0', 'done', { result: { approved: true }, actor: 'ana' });
558
- console.log(inputOf('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
559
- if (JSON.stringify(inputOf('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
560
- throw new Error(`pay received ${JSON.stringify(inputOf('r1:pay:-:0'))}`);
578
+ console.log(handedInput('r1:pay:-:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
579
+ if (JSON.stringify(handedInput('r1:pay:-:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
580
+ throw new Error(`pay received ${JSON.stringify(handedInput('r1:pay:-:0'))}`);
561
581
  }
562
582
 
563
583
  // `pay` settles; its edge reads `needs.run.amount` — 120, the run's — and routes to the audit.
@@ -615,7 +635,7 @@ const scheduler = {
615
635
  addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
616
636
  addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
617
637
  };
618
- const inputOf = (id: string) => added.find((t) => t.id === id)?.payload.input;
638
+ const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
619
639
 
620
640
  const flow: FlowDefinition = {
621
641
  flowId: 'review-each', version: 1, entry: 'each',
@@ -650,12 +670,12 @@ payload.invoices[0]!.amount = 1; // yours to change: the instance keeps its ow
650
670
  // Two asks parked, one per invoice. Answered out of order, each `pay` gets ITS instance's invoice.
651
671
  driver.resolve('r1:review:1:0', 'done', { result: { approved: true }, actor: 'bo' });
652
672
  driver.resolve('r1:review:0:0', 'done', { result: { approved: true }, actor: 'ana' });
653
- console.log(inputOf('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
654
- if (JSON.stringify(inputOf('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
655
- throw new Error(`pay@0 received ${JSON.stringify(inputOf('r1:pay:0:0'))}`);
673
+ console.log(handedInput('r1:pay:0:0')); // { invoice: { id: 'INV-1', amount: 120 }, answer: { approved: true } }
674
+ if (JSON.stringify(handedInput('r1:pay:0:0')) !== '{"invoice":{"id":"INV-1","amount":120},"answer":{"approved":true}}') {
675
+ throw new Error(`pay@0 received ${JSON.stringify(handedInput('r1:pay:0:0'))}`);
656
676
  }
657
- if (JSON.stringify(inputOf('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
658
- throw new Error(`pay@1 received ${JSON.stringify(inputOf('r1:pay:1:0'))}`);
677
+ if (JSON.stringify(handedInput('r1:pay:1:0')) !== '{"invoice":{"id":"INV-2","amount":40},"answer":{"approved":true}}') {
678
+ throw new Error(`pay@1 received ${JSON.stringify(handedInput('r1:pay:1:0'))}`);
659
679
  }
660
680
 
661
681
  // Each `pay` settles; its edge reads `needs.it.amount` — 120 audits, 40 does not.
@@ -672,6 +692,83 @@ const [finding] = validateDefinition(outside, registry);
672
692
  if (finding?.rule !== 'FD-V49' || !finding.detail.includes('move this node inside the map')) throw new Error(`expected FD-V49, got ${finding?.rule}`);
673
693
  ```
674
694
 
695
+ ### `"someNode!error"` — what FAILED, from the nodes after it
696
+
697
+ **`"lookup!error"` names node `lookup`'s ERROR** — the outcome's `error`, the string the scheduler reduced the failure to
698
+ (section 6) — where `"lookup"` names its result (`spec/flow-definition.md` v0.49,
699
+ [ADR 0131](doc/decisions/0131-a-reference-may-name-a-nodes-error.md), since 0.11.0). A failed task's result is `null`, so
700
+ until it a node after a failure edge — an ask asking a person *what now?* above all — could not say what failed.
701
+
702
+ | | |
703
+ |---|---|
704
+ | what it is | the effective outcome's `error`: the record's, or a RESOLUTION's where a person resolved the node (`resolve(id, 'failed', { error })` on a parked task, a declined ask; `'resolved as failed'` where they gave none). **`null` where the output SUCCEEDED** — the key is present, holding `null` |
705
+ | its forms | the plain reference's four: `"lookup!error"`, `"lookup!error?"`, `["lookup!error"]`, `["lookup!error?"]` — the `?` goes LAST. It names the same node's same outputs as `"lookup"`, so `FD-V27`-`FD-V29` and the `?` and array rules are the plain form's, and the fixes `FD-V28` and `FD-V29` name keep the `!error` (`FD-V27` names the node, as for `"lookup"`) |
706
+ | where it is legal | wherever `"lookup"` is: a `task`'s, a `map`'s or an `ask`'s `needs`, and an edge's (`ctx.needs`, section 4) |
707
+ | what it is NOT | a path: the error is a field of the OUTCOME beside `result`, and `error` is the one part a reference may name. `"lookup?!error"`, `"!error"` and `"lookup!err"` are `FD-V26`, each naming its fix |
708
+ | what it costs | nothing to copy — a string or `null`, never the shared frozen copy of the result (above) |
709
+ | in the wiring check | the key composes as `{ type: ['string', 'null'] }` with the sample `''`, whatever the producer declares (section 15) |
710
+ | `referenceTarget` | the error form parses to the plain form's object plus `part: 'error'`, present ONLY there (`AimedReference`), so every other spelling parses as before. A resolver of your own must read `part`; an aiming rule may ignore it |
711
+
712
+ **The default — a failure edge handing `{ error }` to its successor — was declined** (P43 (c2)): it would change what every
713
+ existing failure edge's successor receives. You name the error where you want it.
714
+
715
+ The app's case — `lookup` fails, an ask after its failure edge shows the person why, and the node both roads reach reads
716
+ the error beside the result:
717
+
718
+ ```ts run
719
+ import { Driver, fold, liveToken, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
720
+
721
+ const added: { id: string; payload: { input: unknown } }[] = [];
722
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
723
+ const scheduler = {
724
+ addTask(task: unknown) { added.push(task as { id: string; payload: { input: unknown } }); },
725
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
726
+ };
727
+ const handedInput = (id: string) => added.find((t) => t.id === id)?.payload.input;
728
+ const fire = (type: string, detail: unknown) => { for (const fn of listeners.get(type) ?? []) fn({ detail }); };
729
+
730
+ const flow: FlowDefinition = {
731
+ flowId: 'lookup-or-ask', version: 1, entry: 'lookup',
732
+ nodes: [
733
+ // `optional`, so a failure SETTLES failed and routes the failure edge (a non-optional task would park, section 7).
734
+ { id: 'lookup', kind: 'task', type: 'Work', optional: true, edges: [{ on: ['failed'], to: 'resolve' }, { to: 'publish' }] },
735
+ { id: 'resolve', kind: 'ask', needs: { why: 'lookup!error' },
736
+ answerSchema: { type: 'object', required: ['value'], properties: { value: { type: 'string' } } },
737
+ edges: [{ on: ['failed'], to: 'skipped' }, { to: 'publish' }] },
738
+ { id: 'publish', kind: 'task', type: 'Work', needs: { found: 'lookup', why: 'lookup!error', given: 'resolve?' }, edges: [{ to: 'done' }] },
739
+ { id: 'skipped', kind: 'end', outcome: 'skipped' },
740
+ { id: 'done', kind: 'end', outcome: 'completed' },
741
+ ],
742
+ };
743
+ const types = { workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } } };
744
+ const registry = { predicates: [], ...types };
745
+ if (validateDefinition(flow, registry).length) throw new Error('"lookup!error" is legal on an ask and on a task');
746
+
747
+ const driver = new Driver(flow, { runId: 'r1', scheduler, createTask: (c) => c, predicates: new Map(), joinPolicy: liveToken, ...types });
748
+ driver.start({ invoice: 'INV-?' });
749
+ fire('task.failed', { id: 'r1:lookup:-:0', state: 'failed', result: null, error: 'lookup timed out after 30 s' });
750
+
751
+ // The ask parks with the error: what it shows the person.
752
+ const asked = driver.foldInputs().find((e) => e.kind === 'asked');
753
+ console.log(asked?.kind === 'asked' && asked.input); // { why: 'lookup timed out after 30 s' }
754
+ if (JSON.stringify(asked?.kind === 'asked' && asked.input) !== '{"why":"lookup timed out after 30 s"}') throw new Error('the ask is handed the error');
755
+
756
+ driver.resolve('r1:resolve:-:0', 'done', { result: { value: 'INV-9' }, actor: 'ana' });
757
+ console.log(handedInput('r1:publish:-:0')); // { found: null, why: 'lookup timed out after 30 s', given: { value: 'INV-9' } }
758
+ if (JSON.stringify(handedInput('r1:publish:-:0')) !== '{"found":null,"why":"lookup timed out after 30 s","given":{"value":"INV-9"}}') {
759
+ throw new Error(`publish received ${JSON.stringify(handedInput('r1:publish:-:0'))}`);
760
+ }
761
+ if (fold(driver.foldInputs()).outcome !== 'running') throw new Error('publish is in flight');
762
+
763
+ // A part other than `error`, or the `?` in the wrong place, is refused naming the fix.
764
+ const refuse = (ref: unknown) => validateDefinition({ ...flow, nodes: flow.nodes.map((n) =>
765
+ (n.id === 'publish' ? { ...n, needs: { why: ref } } : n)) } as FlowDefinition, registry).map((f) => `${f.rule}: ${f.detail}`);
766
+ for (const [ref, fix] of [['lookup!err', 'write "lookup!error"'], ['lookup?!error', 'write "lookup!error?"']] as const) {
767
+ const [finding] = refuse(ref);
768
+ if (!finding?.startsWith('FD-V26') || !finding.includes(fix)) throw new Error(`${ref}: ${finding}`);
769
+ }
770
+ ```
771
+
675
772
  ### Validation happens at construction, not at routing time
676
773
 
677
774
  `new Driver(...)` validates the whole definition before anything is queued. A typo cannot become a
@@ -705,6 +802,82 @@ It would have said *every path from a branch entry reaches the join* — which w
705
802
  that routes a branch away from its join invalid input, and that case is the one this library exists
706
803
  to get right (`ADR 0003`). Do not add it.
707
804
 
805
+ ### The definition's SHAPE, as data — `definitionSchema()` and `definitionSchema(registry)`
806
+
807
+ **`definitionSchema()` returns everything above as a JSON Schema, draft 2020-12** (`spec/flow-definition.md` v0.50,
808
+ [ADR 0132](doc/decisions/0132-a-definitions-shape-is-a-schema-built-from-the-validator.md), since 0.11.0): every node kind
809
+ and its keys, which are required, the edges of each kind, the three `when` shapes, the `needs` grammar (`"lookup!error"`
810
+ included), a `retryPolicy`, a `transform` and an `answerSchema` — each key with a `description`. Hand it to any 2020-12
811
+ validator, or to an agent writing a flow, BEFORE the findings. It is BUILT from the validator's own key sets, so its
812
+ keys cannot drift from them (the reference tokens and the operator partition are written beside them and held to the
813
+ 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)).
816
+
817
+ | | |
818
+ |---|---|
819
+ | it refuses | what a node, an edge or a value gets wrong in its OWN shape: an undeclared or missing key, a wrong type, an id outside `[A-Za-z0-9_-]{1,64}`, an unknown kind, a malformed reference, `when`, `retryPolicy` or `transform`, a task's key on an ask, an `answerSchema` outside the subset |
820
+ | it does not | anything that needs ANOTHER node or the REGISTRY — whether `to` names a node, reachability, regions and joins, where a reference points, every registered name (types, pools, groups, languages, predicates, gate types and their routes) — the last is what `definitionSchema(registry)` adds, below |
821
+ | the authority | **`validateDefinition(flow, registry)`**, or `runtime.check(flow)` on a runtime — for all of that and for the whole. What the schema refuses, `validateDefinition` refuses under every registry; a document it accepts may still be refused there. The root `description` says so |
822
+ | not | a check the library runs (nothing evaluates it), nor in [ADR 0053](doc/decisions/0053-the-schema-validator-is-a-built-in-subset-and-a-keyword-outside-it-is-refused.md)'s subset — it uses `$defs`, `$ref`, `oneOf`, `if`/`then`, `pattern` |
823
+
824
+ **`definitionSchema(registry)` is the same shape SPECIALISED to the `Registry` you validate against** (`spec/flow-definition.md`
825
+ v0.51, since 0.11.0) — what an agent most often gets wrong, as data: a task's `type` is one of your node types, and per
826
+ type (an `if`/`then`) its `pool`, its `settings` against its worker's `settingsSchema`, and a `transform` where the type only
827
+ transforms; `workerGroups`, `transform.lang` and a predicate name from your sets; a gate's `type`, and per gate type its
828
+ `settings` and — for a static type — its edges' `route` names. A registry `checkRegistry` refuses (what `new Driver` would
829
+ refuse) is never specialised: the call throws that refusal. What it refuses, `validateDefinition(flow, registry)` refuses.
830
+
831
+ | | |
832
+ |---|---|
833
+ | it cannot cleanly carry | the `settings` check over the EFFECTIVE settings (your type's defaults merged under the node's): a key the defaults supply is not required of the node, and a default's own value — and a top-level keyword of your settingsSchema other than `properties`, `required` and `additionalProperties` — is not checked; a `minLength` above 1 inside one of your schemas, emitted as half of it rounded up (your schemas count UTF-16 units, JSON Schema counts code points); a DERIVED gate type's routes (`switch`'s are its cases); every route wired EXACTLY ONCE; a gate type's `settingsProblem` and a language's `compile` |
834
+ | annotation only | each node type's worker `inputSchema` — at `$defs.inputSchema` in its type's branch, constraining nothing (it is runtime input) |
835
+ | the authority | as above, against that registry. The root `description` names every row of this table |
836
+ | mind | a validator that tests a property with `in` (`@cfworker/json-schema` 4.1.1 does) misreads a schema property of yours NAMED as an `Object.prototype` member — `constructor`, `toString`, `__proto__` — and refuses or throws where 2020-12 accepts; name your settings otherwise |
837
+
838
+ ```ts run
839
+ import { definitionSchema, validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
840
+
841
+ const schema = definitionSchema();
842
+ console.log(schema.$schema); // https://json-schema.org/draft/2020-12/schema
843
+ if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') throw new Error('a 2020-12 document');
844
+ if (!String(schema.description).includes('validateDefinition')) throw new Error('it names the authority for the rest');
845
+
846
+ type Def = { properties: Record<string, { description?: string }>; required: string[]; additionalProperties: boolean };
847
+ const defs = schema.$defs as Record<string, Def> & { reference: { anyOf: { pattern?: string }[] } };
848
+ console.log(defs.askNode.required); // [ 'id', 'kind', 'edges' ]
849
+ if (defs.askNode.additionalProperties !== false || 'type' in defs.askNode.properties) throw new Error('an ask declares no type');
850
+ if (!defs.taskNode.properties.retryPolicy?.description) throw new Error('every key is described');
851
+
852
+ // The reference grammar, as a pattern any validator can apply — the error form accepted, a misplaced "?" not.
853
+ const reference = new RegExp(defs.reference.anyOf[0]!.pattern!);
854
+ if (!reference.test('lookup!error?') || reference.test('lookup?!error')) throw new Error('the "!error" grammar');
855
+
856
+ // A shape the schema accepts can still be refused: an edge to a node that does not exist is the VALIDATOR's.
857
+ const dangling = { flowId: 'f', version: 1, entry: 'a', nodes: [{ id: 'a', kind: 'task', type: 'Work', edges: [{ to: 'ghost' }] }] } as FlowDefinition;
858
+ const rules = validateDefinition(dangling, { predicates: [], workers: { w: { url: 'worker.js' } }, nodeTypes: { Work: { worker: 'w' } } }).map((f) => f.rule);
859
+ if (rules.join() !== 'FD-V2') throw new Error(`the graph is validateDefinition's: ${rules.join()}`);
860
+ if (definitionSchema() === schema) throw new Error('a new object per call');
861
+
862
+ // Specialised to a registry: its node types (the built-in Transform beside yours), each type's pools and settings.
863
+ const registry = {
864
+ predicates: ['isUrgent'],
865
+ workers: { w: { url: 'worker.js', settingsSchema: { type: 'object', properties: { depth: { type: 'integer', minimum: 1 } }, additionalProperties: false } } },
866
+ nodeTypes: { Work: { worker: 'w' } },
867
+ };
868
+ const specialised = definitionSchema(registry);
869
+ type Task = { properties: { type: { enum: string[] } }; allOf: { then: { properties: { pool: { enum: string[] }; settings?: unknown } } }[] };
870
+ const task = (specialised.$defs as { taskNode: Task }).taskNode;
871
+ console.log(task.properties.type.enum); // [ 'Work', 'Transform' ]
872
+ console.log(task.allOf[0]!.then.properties.pool.enum); // [ 'Work' ]
873
+ if (task.allOf[0]!.then.properties.settings === undefined) throw new Error('Work\'s settings are held to its worker\'s schema');
874
+ if (!String(specialised.description).includes('EXACTLY ONCE')) throw new Error('it names what it cannot carry');
875
+ // A registry new Driver would refuse is never specialised.
876
+ let refused = '';
877
+ try { definitionSchema({ ...registry, nodeTypes: { Lost: { worker: 'missing' } } }); } catch (e) { refused = (e as Error).name; }
878
+ if (refused !== 'WorkerMissing') throw new Error(`a refused registry throws its refusal: ${refused}`);
879
+ ```
880
+
708
881
  ---
709
882
 
710
883
  ## 4. Decisions and predicates
@@ -841,7 +1014,7 @@ flagged low confidence* was not expressible until v0.12 —
841
1014
  answering [Q8](doc/questions/q8.md).
842
1015
 
843
1016
  **An edge may carry `needs`, and its predicate sees the result under `ctx.needs`.** Same grammar as
844
- a node's (section 3) — a node id, or a one-element array holding one, and since 0.8.0 `"$payload"` and `"$item"` —
1017
+ a node's (section 3) — a node id, or a one-element array holding one, since 0.8.0 `"$payload"` and `"$item"`, and since 0.11.0 a node's error, `"n!error"` —
845
1018
  **minus `"."`**, which an edge has no use for: *what a predicate would have received anyway* is `ctx.outcome`. `"$payload"`
846
1019
  and `"$item"` are not refused there: a predicate has no other road to the run's payload or to its map instance's item
847
1020
  (section 3's two subsections route on them).
@@ -911,7 +1084,9 @@ instance, from what the instance KEEPS**: its item, while a node of it may still
911
1084
  (section 3).
912
1085
 
913
1086
  **`ctx.needs` is `{}` when the edge declares nothing**, never `undefined`; it is **frozen**, so a
914
- predicate that writes to it throws rather than editing what the next predicate reads; and a reference
1087
+ predicate that writes to it throws rather than editing what the next predicate reads — and from 0.10.0 so does a
1088
+ write INTO a value under a reference or `"$payload"` key, the run's one shared frozen copy (section 3), and a predicate
1089
+ that throws stops the run; and a reference
915
1090
  the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, section 9) exactly as a
916
1091
  node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
917
1092
  and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
@@ -1447,10 +1622,17 @@ keeps waiting, and the run is not stuck.
1447
1622
  ([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`):
1448
1623
 
1449
1624
  - **Nothing is handed to the scheduler** — the scheduler never sees the ask's id. The driver appends ONE fold input,
1450
- `{ kind: 'asked', taskId, input }`; the fold reads the node `suspended` and the run `awaiting_action`, exactly the
1625
+ `{ kind: 'asked', taskId, input, observedAt? }`; the fold reads the node `suspended` and the run `awaiting_action`, exactly the
1451
1626
  words a parked task gives (so `done` waits, below), and the node's entry in `nodes` carries **`input`** — what reached
1452
1627
  the ask, its `needs` composed — kept after the answer. Tell an ask from a parked failure by the node's KIND, or by the
1453
1628
  `asked` element, never by an error string. In a fan-out the ask's branch is LIVE, so **its join waits for the person**.
1629
+ - **WHEN it was asked** (since 0.11.0, [ADR 0130](doc/decisions/0130-a-regenerated-asked-element-keeps-the-stored-runs-time.md)):
1630
+ the element's `observedAt` is the driver's `now` — the clock an outcome record's `observedAt` reads — and the node's
1631
+ entry carries it as **`askedAt`**, kept after the answer as `input` is. **A resume KEEPS the stored time**: the replay
1632
+ 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.
1454
1636
  - **The answer is `driver.resolve(taskId, …)`** — the same call as below. `'done'` with `result` the answer, held to the
1455
1637
  ask's `answerSchema` (refused as `BoundaryRefused` with NOTHING appended and the ask still parked — answer again;
1456
1638
  `validateBoundaries: false` turns it off); `'failed'` with an `error` is the person DECLINING, held to nothing.
@@ -1463,7 +1645,7 @@ keeps waiting, and the run is not stuck.
1463
1645
  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
1464
1646
  **`schedulerTimestamp`, `startTime`, `endTime` and `executionTime` all `null`** — no scheduler was involved.
1465
1647
  - **BREAKING in 0.8.0** (P35 decision 8): `FoldInput` gains the kind `'asked'`, and `OutcomeRecord.schedulerTimestamp`
1466
- is `number | null` — `null` only on an ask's effective outcome. `NodeRun.input` is optional and additive.
1648
+ is `number | null` — `null` only on an ask's effective outcome. `NodeRun.input` is optional and additive — as, since 0.11.0, are the element's `observedAt` and `NodeRun.askedAt`.
1467
1649
  - **An answer given from a callback on the driver's own step** — `onFoldInput` on the `asked` element included — is
1468
1650
  checked at once (a refusal is thrown or `false` returned right there) and, accepted, `resolve()` returns `true` and the
1469
1651
  resolution is appended when the step ends: so the run's checkpoint resumes. A second answer while it is pending is
@@ -1522,6 +1704,9 @@ console.log(added, parked.outcome, parked.nodes['r1:approve:-:0'].input);
1522
1704
  // [ 'r1:draft:-:0' ] awaiting_action { text: 'a draft' }
1523
1705
  if (added.join() !== 'r1:draft:-:0' || parked.outcome !== 'awaiting_action') throw new Error('the ask should park with nothing dispatched');
1524
1706
  if (JSON.stringify(parked.nodes['r1:approve:-:0'].input) !== '{"text":"a draft"}') throw new Error('the ask keeps what reached it');
1707
+ // And WHEN it was asked: the element's `observedAt` (the driver's clock, `Date.now()` by default) is the entry's `askedAt`.
1708
+ const askedEl = driver.foldInputs().find((e) => e.kind === 'asked');
1709
+ if (askedEl?.kind !== 'asked' || typeof askedEl.observedAt !== 'number' || parked.nodes['r1:approve:-:0'].askedAt !== askedEl.observedAt) throw new Error('the ask keeps when it was asked');
1525
1710
 
1526
1711
  // An answer its schema refuses is thrown at you BY NAME — nothing appended, the ask still parked.
1527
1712
  let refused: unknown = null;
@@ -1754,7 +1939,7 @@ need the state; step only where you hold a short one. Both return exactly what t
1754
1939
  ([ADR 0121](doc/decisions/0121-the-driver-keeps-a-per-task-view-of-its-log.md)): every question it asks of its log about
1755
1940
  ONE task — a settlement's effective outcome, whether a task is parked, what is in flight, what a `needs` can reach — reads
1756
1941
  that task's records alone (and from 0.9.1 a `needs` read takes the outputs it is handed rather than searching its node's —
1757
- `RN-R13` v0.32, [ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md)), where 0.8.0 scanned the whole log (a 16,000-item `map` driven on a scheduler double: 999 ms in
1942
+ `RN-R13` v0.32, [ADR 0124](doc/decisions/0124-a-reference-read-costs-its-answer.md); and from 0.10.0 it is handed ONE shared copy of each output, made at the first read and kept for the run, rather than a clone per read — `RN-R13` v0.33, [ADR 0126](doc/decisions/0126-a-references-value-is-one-frozen-copy-shared-by-every-reader.md)), where 0.8.0 scanned the whole log (a 16,000-item `map` driven on a scheduler double: 999 ms in
1758
1943
  0.8.0, 229 ms from 0.9.0, node 24). `snapshot()`, `foldInputs()` and `checkpoint()` still hand you a copy of the whole run,
1759
1944
  O(run) a call: read them when you need the run, not after every event.
1760
1945
 
@@ -1915,6 +2100,7 @@ goes no longer folds it a second time in its own `onFoldInput`:
1915
2100
  | `handle.outcome()` | the run's outcome now | O(1) — **the read to make after every input** |
1916
2101
  | `handle.node(taskId)` | that task's `nodes` entry now — the same frozen object `state()` holds — or `null` for a task the run never dispatched | O(1) in the run — its first read of an entry freezes that entry once |
1917
2102
  | `handle.state()` | the whole `RunState` now: a COPY, **frozen at every depth**, and the SAME object on every read until an input moves the run | O(run) for the first read after each input that moved it, nothing after — **so reading it after EVERY input costs O(run²) over a run**: read it when you draw the run |
2103
+ | `handle.inputOf(target)` | what a node of the run was HANDED — a task's envelope, any other node's input (since 0.10.0, *What a node was HANDED* below) | O(the run so far) per call — a replay, never cached: read it when a person asks |
1918
2104
 
1919
2105
  **At a quiescent point `handle.state()` IS `fold(handle.driver.foldInputs())`** — the same rule as above: read on a
1920
2106
  `queueMicrotask` from `onFoldInput`, or outside the driver's turn. **It is not a live view** — a read is the state at that
@@ -1975,6 +2161,108 @@ try { (end.nodes[a]!.result as { score: number }).score = 1; } catch { wrote = f
1975
2161
  if (wrote) throw new Error('a write into the run`s state must throw — it is a copy to read, not a document to edit');
1976
2162
  ```
1977
2163
 
2164
+ ### What a node was HANDED — `inputOf` and `handle.inputOf`, after the fact
2165
+
2166
+ **A run keeps what its tasks RETURNED, never what they were handed** — `RunState` holds an ask's input (`NodeRun.input`)
2167
+ and no task's ([ADR 0046](doc/decisions/0046-a-run-retains-what-its-tasks-returned-and-a-region-releases-its-collection-when-its-join-becomes-terminal.md)),
2168
+ and no function over `RunState` can rebuild it exactly: the entry's payload, the edge each node took, what was visible at
2169
+ the moment of dispatch and a gate's memory are not in it. **From 0.10.0 you READ it instead** (`RN-R45`,
2170
+ [ADR 0128](doc/decisions/0128-what-a-node-was-handed-is-read-by-replaying-the-run-to-its-composition.md)):
2171
+
2172
+ ```
2173
+ inputOf(flow, options, { header, events }, target) // the root export — a stored run, its runtime gone or not
2174
+ handle.inputOf(target) // a live run: the same function over its own checkpoint()
2175
+ ```
2176
+
2177
+ It builds a driver for the stored run — DEAF: no callback, nothing handed to any scheduler — and REPLAYS the stored
2178
+ events, exactly as `resume()` does, until the target's composition. **So the answer is the driver's own composition,
2179
+ not a second one**, and costs the run's prefix up to the target. `options` is what the run's driver was built from:
2180
+ `runtime.registry`, plus its `joinPolicy` where you did not use `liveToken`.
2181
+
2182
+ | target | the node is | `inputs` |
2183
+ |---|---|---|
2184
+ | `{ taskId }` or `{ nodeId, branchPath }` | a **task** | one `envelope`: `{ settings, input, transform? }` — what the driver handed `createTask`, BEFORE any transform; a retry and a resolution share it |
2185
+ | the same | an **ask** | one `received` — equal to its `asked` element's `input` |
2186
+ | `{ nodeId, branchPath }` | a **gate**, **fanout**, **map** or **end** | one `received` — what reached it on that path; an end's path is the branch that reached it |
2187
+ | `{ nodeId, branchPath }` | a **join**, at an ARRIVING branch's path | one `arrival` — what that branch carried in, a late arrival after an early firing included |
2188
+ | `{ nodeId, branchPath }` | a **join**, at its OPENER's path | one `collection` — what it handed on when it fired |
2189
+ | `{ nodeId }` | anything | EVERY one of the above the run reached, in the order it reached them — the paths an END was reached on included, which nothing else names |
2190
+
2191
+ A path target answers the FIRST composition on its path: an end shared by two sibling regions can be reached twice on one
2192
+ path, and only `{ nodeId }` lists both.
2193
+
2194
+ **It never throws for the run or the target** — only for a flow or a registry no driver can be built from, as `new Driver` throws — `{ ok: false, reason, index, message }`: `unknown` (a target this run's
2195
+ ids cannot name), `not-reached`, `diverged` (the replay produced something other than a stored element BEFORE the
2196
+ target — a changed predicate or definition), and `format` / `flow` (the header, refused as a resume refuses it). **A
2197
+ halted, cancelled or stopped run IS read**, up to its halt. **What it cannot see**: a change to the flow or the registry
2198
+ AFTER the target — or one that moves only an envelope, even at the target: the events carry no payloads, so a reading
2199
+ answers with the definition you hand it. **It re-runs your predicates and gate types**, as a resume does. The value is
2200
+ the reading's own — a value a `needs` reference composed is frozen, as every reader's is (section 3), the rest yours —
2201
+ and nothing of a live run is reachable through it. **The input a task's worker sees AFTER its transform is not answered**
2202
+ ([Q760](doc/questions/q760.md), resolved by P43: the road serves): hand the envelope's `input` to `tryTransform` (section 15). **BREAKING in 0.10.0**:
2203
+ `RunHandle` gains `inputOf`, which breaks only code that BUILDS a handle.
2204
+
2205
+ ```ts run
2206
+ import { Driver, inputOf, liveToken, type FlowDefinition, type InputOfOptions } from 'fyflow-orchestration';
2207
+
2208
+ // A scheduler double, section 12's shape: it records what it is handed and fires only what we tell it to.
2209
+ const added: { id: string; payload: unknown }[] = [];
2210
+ const listeners = new Map<string, ((e: { detail: unknown }) => void)[]>();
2211
+ const scheduler = {
2212
+ addTask(task: unknown) { added.push(task as { id: string; payload: unknown }); },
2213
+ addEventListener(type: string, fn: (e: { detail: unknown }) => void) { listeners.set(type, [...(listeners.get(type) ?? []), fn]); },
2214
+ };
2215
+ const settle = (id: string, result: unknown) => {
2216
+ const detail = { id, state: 'done', result, attempts: 0, timestamp: 1, startTime: 1, endTime: 2, executionTime: 1 };
2217
+ for (const fn of listeners.get('task.completed') ?? []) fn({ detail });
2218
+ };
2219
+
2220
+ // A map over invoices; each check reads its invoice and the run's payload; a rejected one ENDS in the map.
2221
+ const flow: FlowDefinition = {
2222
+ flowId: 'invoices', version: 1, entry: 'fetch',
2223
+ nodes: [
2224
+ { id: 'fetch', kind: 'task', type: 'Fetch', edges: [{ to: 'each' }] },
2225
+ { id: 'each', kind: 'map', over: 'invoices', branch: 'check' },
2226
+ { id: 'check', kind: 'task', type: 'Check', needs: { invoice: '.', run: '$payload' }, edges: [
2227
+ { when: { path: 'outcome.result.ok', op: 'eq', value: false }, to: 'rejected' },
2228
+ { to: 'joined' },
2229
+ ] },
2230
+ { id: 'rejected', kind: 'end', outcome: 'rejected' },
2231
+ { id: 'joined', kind: 'join', of: 'each', edges: [{ to: 'done' }] },
2232
+ { id: 'done', kind: 'end', outcome: 'completed' },
2233
+ ],
2234
+ };
2235
+ // What the run's driver was built from — and so what a reading builds its own from (joinPolicy: liveToken by default).
2236
+ const options: InputOfOptions = { predicates: new Map(), workers: { w: { url: 'worker.js' } }, nodeTypes: { Fetch: { worker: 'w' }, Check: { worker: 'w' } } };
2237
+ const driver = new Driver(flow, { runId: 'r1', scheduler, ...options, joinPolicy: liveToken });
2238
+ driver.start({ tenant: 't1' });
2239
+ settle('r1:fetch:-:0', { invoices: [{ id: 'INV-1' }, { id: 'INV-2' }, { id: 'INV-3' }] });
2240
+ settle('r1:check:2:0', { ok: false });
2241
+ settle('r1:check:0:0', { ok: true });
2242
+ settle('r1:check:1:0', { ok: false });
2243
+
2244
+ // What a store keeps: the header and the events (section 18). Read them back — the run's process may be long gone.
2245
+ const stored = driver.checkpoint();
2246
+ const task = inputOf(flow, options, stored, { taskId: 'r1:check:1:0' });
2247
+ console.log(task.ok && task.inputs[0]!.value); // { settings: {}, input: { invoice: { id: 'INV-2' }, run: { tenant: 't1' } } }
2248
+ const ends = inputOf(flow, options, stored, { nodeId: 'rejected' });
2249
+ console.log(ends.ok && ends.inputs.map((i) => [i.branchPath, i.value])); // [['2', { ok: false }], ['1', { ok: false }]]
2250
+ const nope = inputOf(flow, options, stored, { nodeId: 'audit' });
2251
+ console.log(nope.ok ? 'answered' : nope.reason); // 'unknown'
2252
+
2253
+ // The answer IS what the driver handed the scheduler — the same envelope, built by the same code.
2254
+ const handed = added.find((t) => t.id === 'r1:check:1:0')!.payload;
2255
+ if (!task.ok || JSON.stringify(task.inputs[0]!.value) !== JSON.stringify(handed)) throw new Error('a task`s answer is the envelope it was handed');
2256
+ if (JSON.stringify(handed) !== '{"settings":{},"input":{"invoice":{"id":"INV-2"},"run":{"tenant":"t1"}}}') throw new Error(`check@1 was handed ${JSON.stringify(handed)}`);
2257
+ // A node id alone: every path the END was reached on, in the order the run reached them — 2 before 1.
2258
+ if (!ends.ok || JSON.stringify(ends.inputs.map((i) => i.branchPath)) !== '["2","1"]') throw new Error('rejected was reached at 2, then 1');
2259
+ if (ends.inputs.some((i) => i.handed !== 'received' || JSON.stringify(i.value) !== '{"ok":false}')) throw new Error('an end is handed what reached it');
2260
+ if (nope.ok || nope.reason !== 'unknown') throw new Error('a node the flow does not have is unknown');
2261
+ // A prefix of the events — a store that has not caught up — reads what it holds and no more.
2262
+ const early = inputOf(flow, options, { header: stored.header, events: stored.events.slice(0, 4) }, { taskId: 'r1:check:1:0' });
2263
+ if (early.ok || early.reason !== 'not-reached') throw new Error('a task the prefix did not dispatch is not-reached');
2264
+ ```
2265
+
1978
2266
  ### Pausing a run — `driver.pause` / `driver.unpause`, and a FLAG that is not an outcome
1979
2267
 
1980
2268
  **`driver.pause({ actor })` holds a run without ending it, and `driver.unpause({ actor })` lets it go on** (`RN-R35`,
@@ -2855,6 +3143,7 @@ if (!haltedRun.haltedBy?.startsWith('UncloneableResult')) throw new Error(`halte
2855
3143
  | ~~**map-style fan-out**~~ — **BUILT** since [W33](doc/work-items/w33-a-map-at-run-time-arity.md); see section 3 |
2856
3144
  | ~~**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) |
2857
3145
  | a **live, mutable view** of a run's state, or the state handed to `onFoldInput` beside each input | **no** — [P39](doc/plans/p39-what-a-step-costs-and-where-a-live-run-state-is-read.md) (b), [ADR 0122](doc/decisions/0122-a-runtimes-live-state-is-a-frozen-copy-on-its-handle.md): `handle.state()` is a frozen COPY, cached until the run moves, and `handle.outcome()` / `handle.node()` are the O(1) reads — section 8 |
3146
+ | a task's INPUT kept in the run state — `NodeRun.input` for a task, or `pathEnded` carrying the value that reached an end | **no** — [ADR 0046](doc/decisions/0046-a-run-retains-what-its-tasks-returned-and-a-region-releases-its-collection-when-its-join-becomes-terminal.md): a run keeps what its tasks returned. **READ it instead**, since [W205](doc/work-items/w205-what-a-node-was-handed-read-by-replaying-the-run.md): `inputOf(flow, options, { header, events }, target)` or `handle.inputOf(target)` replays the run to that node — section 8. The input a worker sees AFTER its transform: **no** ([Q760](doc/questions/q760.md), resolved — the envelope's `input` through `tryTransform` serves) |
2858
3147
  | a **join timeout** | nothing. A stuck join is detected by the `stuck` outcome, with no clock |
2859
3148
  | **per-join policy choice** | one policy per driver |
2860
3149
  | a **declared event family** for `fyflow-react-components` | `onRegionEvent`, `onFoldInput` and `joinStatusOf()` — see below |
@@ -4024,6 +4313,7 @@ whatever arcs reach the node, on the entry node too** (W166, P37 stop-and-ask (d
4024
4313
  | `shape` | `'given'` — not an arc; `checkWiring` never produces it |
4025
4314
  | `producer` | **absent** — the value came from no node (and `through` too) |
4026
4315
  | `sample` | `{ source: 'given', value, result }` — the value you gave, and what the transform made of it; no `arity`. **Never `'example'` or `'witness'`** |
4316
+ | `cause` | **on `transform-fails` only** (since 0.11.0, every road — `checkWiring`'s arcs too): `'threw'` — the worker half did not import, had no default export, or its `compile` or `evaluate` threw — or `'schema'`, a result the node's `inputSchema` refused — the type `TransformFailureCause`, a root export. Absent on every other kind. Read it rather than `why`'s prefix |
4027
4317
  | `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)` |
4028
4318
 
4029
4319
  **The value is `$` as you give it: no `needs` is composed over it**, because a `needs` composes what
@@ -4080,6 +4370,7 @@ if (entry!.kind !== 'transform-passes') throw new Error(`expected the given valu
4080
4370
  const [wrong] = await tryTransform(registry, flow, 'read', rename, { input: { folder: 3 } });
4081
4371
  console.log(`${wrong!.kind}: ${wrong!.why}`); // transform-fails: the transform's result on the given input fails the consumer's inputSchema — $.source expected string, ...
4082
4372
  if (wrong!.kind !== 'transform-fails' || !wrong!.why.includes('$.source expected string')) throw new Error(`expected the finding, got ${wrong!.why}`);
4373
+ if (wrong!.cause !== 'schema') throw new Error(`a result the schema refused is cause 'schema', got ${wrong!.cause}`);
4083
4374
 
4084
4375
  // THE INNER NODE. Without an input its one arc runs on `reader`'s declared EXAMPLE; given a value, it runs on that.
4085
4376
  const join = { lang: 'jsonata', expr: '{ "text": $join(lines, " ") }' };
@@ -4092,6 +4383,8 @@ if (JSON.stringify(mine!.sample!.result) !== JSON.stringify({ text: 'typed by ha
4092
4383
  // An expression the engine refuses: `transform-fails`, naming the throw — the compile error, on the given input.
4093
4384
  const [broken] = await tryTransform(registry, flow, 'sum', { lang: 'jsonata', expr: '{{{' }, { input: { lines: [] } });
4094
4385
  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}`);
4386
+ // The same, as DATA (since 0.11.0): `cause` is 'threw' — no result was produced — where the finding above is 'schema'.
4387
+ if (broken!.cause !== 'threw' || entry!.cause !== undefined) throw new Error(`a throw is cause 'threw', and a passing verdict has none — got ${broken!.cause}, ${entry!.cause}`);
4095
4388
  ```
4096
4389
 
4097
4390
  **BREAKING in 0.8.0** (P37 decision 10): **`SampleSource` gains `'given'`**, **`WiringShape` gains
@@ -4398,7 +4691,7 @@ no serializer. A CHECKPOINT is `{ header, events }`
4398
4691
  ([ADR 0064](doc/decisions/0064-a-checkpoint-is-the-whole-event-sequence-and-a-header-and-it-is-a-copy.md)) —
4399
4692
  the header is five facts the events cannot regenerate (`format`, `runId`, `flowId`, `version` and a
4400
4693
  copy of the entry payload), and `events` is every fold input so far, so **every event after a
4401
- checkpoint is an append to it**. You never have to call `driver.checkpoint()` to keep one:
4694
+ checkpoint is an append to it**. **The same header and events answer what any node was HANDED, long after the run** — `inputOf`, section 8 (since 0.10.0). You never have to call `driver.checkpoint()` to keep one:
4402
4695
 
4403
4696
  | | where you get it | what you do with it |
4404
4697
  |---|---|---|
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.9.1`.
20
+ Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.11.0`.
21
21
 
22
22
  ## Install
23
23