fyflow-orchestration 0.12.0 → 0.13.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
@@ -818,7 +818,8 @@ entries are held to the id charset, a non-string refused — as `validateDefinit
818
818
  tripped over it, and is refused now. **And the other way, since 0.12.0** (`spec/flow-definition.md` v0.53,
819
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
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.
821
+ `KEY_PATH_RE`, the gate key's, is the exported constant whose `source` moved. Only more is accepted. *(It, the parse and
822
+ the read a host may call are section 4's* Reading a key path the way the library does*.)*
822
823
 
823
824
  | | |
824
825
  |---|---|
@@ -894,14 +895,14 @@ a flow document can say what a branch depends on without any code at all, which
894
895
  an agent emit a complete flow. A name still works and is what a predicate that COMPUTES uses.
895
896
 
896
897
  ```ts run
897
- import { validateDefinition, type FlowDefinition } from 'fyflow-orchestration';
898
+ import { evaluateWhen, validateDefinition, type FlowDefinition, type When } from 'fyflow-orchestration';
898
899
 
899
900
  // A `when` is EXACTLY ONE of three shapes, told apart by which keys it carries.
900
901
  const flow: FlowDefinition = {
901
902
  flowId: 'declared', version: 1, entry: 'score',
902
903
  nodes: [
903
904
  { id: 'score', kind: 'task', type: 'ScoreWorker', edges: [
904
- // 1. a COMPARISON — a key path over the context, an operator, a literal.
905
+ // 1. a COMPARISON — a key path over the context, an operator, a literal (or `valueFrom`, below).
905
906
  { when: { path: 'outcome.result.confidence', op: 'lt', value: 0.5 }, to: 'review' },
906
907
  // 2. a flat GROUP — `all` or `any`, whose members are comparisons.
907
908
  { when: { any: [
@@ -940,16 +941,41 @@ if (refuse({ path: 'result.confidence', op: 'lt', value: 0.5 })[0] !== 'FD-V43')
940
941
  if (refuse({ path: 'outcome.result.name', op: 'matches', value: '^a' })[0] !== 'FD-V44') throw new Error('an unknown operator is FD-V44');
941
942
  // FD-V45 — a group is FLAT, and a member naming a registered predicate could be IMPURE.
942
943
  if (refuse({ all: [{ any: [{ path: 'outcome.result.a', op: 'eq', value: 1 }] }] })[0] !== 'FD-V45') throw new Error('nesting is FD-V45');
944
+
945
+ // `valueFrom` (since 0.13.0) — compare against ANOTHER key path over the same context, in place of `value`.
946
+ // Exactly one of the two: both, or neither, is FD-V42. It takes the same roots as `path` (FD-V43).
947
+ if (refuse({ path: 'outcome.result.score', op: 'gte', valueFrom: 'outcome.result.threshold' }).length !== 0) throw new Error('valueFrom alone is valid');
948
+ if (refuse({ path: 'outcome.result.score', op: 'gte', value: 50, valueFrom: 'outcome.result.threshold' })[0] !== 'FD-V42') throw new Error('both is FD-V42');
949
+ if (refuse({ path: 'outcome.result.score', op: 'gte', valueFrom: 'result.threshold' })[0] !== 'FD-V43') throw new Error('a rootless valueFrom is FD-V43');
950
+ // A `needs` path — on either side — reads a key THIS EDGE's `needs` declares; `refuse`'s edge declares none, so FD-V43.
951
+ if (refuse({ path: 'outcome.result.size', op: 'lte', valueFrom: 'needs.limit.max' })[0] !== 'FD-V43') throw new Error('an undeclared needs key is FD-V43');
952
+
953
+ // What it resolves to is compared as a literal would be — and an ABSENT one is NO MATCH for every operator,
954
+ // so `x gt y` and `x lte y` are BOTH false where `y` is absent. No `default` stands in for it: that is the left's.
955
+ const ask = (when: When, result: unknown) => evaluateWhen(when, { outcome: { result } as never, needs: {} }, new Map());
956
+ const gt: When = { path: 'outcome.result.x', op: 'gt', valueFrom: 'outcome.result.y' };
957
+ const lte: When = { path: 'outcome.result.x', op: 'lte', valueFrom: 'outcome.result.y' };
958
+ if (!ask(gt, { x: 5, y: 3 }) || ask(lte, { x: 5, y: 3 })) throw new Error('5 gt 3, and not 5 lte 3');
959
+ if (ask(gt, { x: 5 }) || ask(lte, { x: 5 })) throw new Error('y absent: both are false');
960
+ // A resolved object or array is no match too, `ne` included — `!==` would have said true.
961
+ if (ask({ path: 'outcome.result.x', op: 'ne', valueFrom: 'outcome.result.y' }, { x: 5, y: { v: 5 } })) throw new Error('ne against an object is no match');
943
962
  ```
944
963
 
945
964
  | | |
946
965
  |---|---|
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 |
966
+ | `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. **A `needs` path names a key THIS EDGE's `needs` declares** (since 0.13.0, `FD-V43`): any other would be absent for ever |
948
967
  | `op` | one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `startsWith`, and **nothing else** |
949
- | `value` | a scalar: `null`, a boolean, a finite number or a string |
968
+ | `value` | a scalar: `null`, a boolean, a finite number or a string. **Exactly one of `value` and `valueFrom`** (`FD-V42`) |
969
+ | `valueFrom` | since 0.13.0, in place of `value` — **another key path over the same context**, held to everything `path` is; what it resolves to is compared as a literal would be. **No match where it does not resolve, or resolves to an object or an array** |
950
970
  | `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 |
951
971
  | `length` | optional `true` — compare the LENGTH of an array or a string, after `default` |
952
972
 
973
+ **The type moved with it, in 0.13.0.** `PredicateLeaf` is a two-member union — `{ value, valueFrom?: never }` or
974
+ `{ valueFrom, value?: never }` over the shared `PredicateLeafBase` — so a comparison with both, or neither, fails to
975
+ compile, and code READING `leaf.value` now sees `PredicateScalar | undefined`: tell the two apart by `'valueFrom' in leaf`.
976
+ `PREDICATE_LEAF_KEYS` gained `valueFrom` and `PREDICATE_LEAF_REQUIRED` lost `value`; `predicateProblem(when, edgeNeeds?)`
977
+ takes the keys an edge's `needs` declares, and asks the `needs` rule only when given them.
978
+
953
979
  **Three things to know before you write one.**
954
980
  **A comparison between kinds is no match, never a coercion** — `{ op: 'lt', value: 50 }` is false
955
981
  against the string `"9"` where JavaScript's `<` would say true.
@@ -960,6 +986,10 @@ holding `null` as well as a missing one.
960
986
  that holds over NUMBERS, and over an absent path where you declared a `default`. Over a value an
961
987
  ordering operator cannot compare, a string or a boolean, **both it and its complement are false**.
962
988
  `startsWith` has no complement here at all.
989
+ **An absent `valueFrom` is no match for every operator, and nothing makes it otherwise** — `default` and `length`
990
+ are the LEFT side's, and there is no `valueDefault`. So `x gt y` and `x lte y` are BOTH false where `y` is
991
+ absent: route the absent case by its own edge if it matters. And a `valueFrom` resolving to an object or an
992
+ array is no match even for `ne`, where an object on the LEFT with `ne` is still true — the left side is 0.12.0's.
963
993
  **A declared predicate CANNOT be impure.** There is no clock, no random source and no way to name
964
994
  anything but the context, so re-evaluating it over the same log gives the same answer. A registered
965
995
  name has no such guarantee — which is the trade, and why a regular expression, a date window or a
@@ -1097,6 +1127,58 @@ the run did not produce stops the run with an `UnresolvedReference` (`FD-R3`, se
1097
1127
  node's does. **An edge with no `when` may carry a `needs` and nothing will ever read it** — accepted
1098
1128
  and inert, which `spec/flow-definition.md` section 3 records rather than refuses.
1099
1129
 
1130
+ ### Reading a key path the way the library does — `readKeyPath`, `keyPathSteps`, `KEY_PATH_RE`, `PREDICATE_ROOTS`
1131
+
1132
+ **A host that suggests, checks or previews a key path need not keep its own copy of the spelling.** Five names, all
1133
+ from the root entry in node, the browser and Deno — `readKeyPath`, `KEY_PATH_RE` and `PREDICATE_ROOTS` since before
1134
+ 0.12.0, **`keyPathSteps` and its `KeyPathStep` type since 0.13.0** (P45, fyflow-app's pass 6 #2). They are the ones a
1135
+ gate's `settings.key`, a comparison's `path` and the wiring check read, so a host using them cannot read a path
1136
+ differently from a run.
1137
+
1138
+ | name | what it is |
1139
+ |---|---|
1140
+ | `KEY_PATH_RE` | the GRAMMAR (`spec/flow-definition.md` section 2's KEY PATH): dot-separated object keys, each `[A-Za-z0-9_-]{1,64}` optionally followed by array indexes `[n]` — `n` is `0` or a number with no leading zero, at most 999,999,999 — never an index leading the path; no quoted key, no wildcard, no expression. A gate's `key` is refused `FD-V40` outside it |
1141
+ | `keyPathSteps(path)` | the PARSE: a path's steps in order, each `{ key }` or `{ index }` — that union is `KeyPathStep`. **Total**: any string has steps, and a segment the grammar does not spell is ONE literal key (`files[*]` is a key named `files[*]`), so test the path with `KEY_PATH_RE` first |
1142
+ | `readKeyPath(value, path)` | the READ: a KEY step is an OWN key of a PLAIN object, an INDEX step an own element of a REAL array; anything else — a missing key, `null`, an array under a key step, a class instance, a hole — is `undefined`. **A plain numeric segment is a KEY**: `xs.1` is nothing over an array, `xs[1]` is its element. The value at the end is returned whatever it is |
1143
+ | `PREDICATE_ROOTS` | a task's or a join's edge comparison's two ROOTS, `outcome` and `needs` (`FD-V43`): the first key of its `path` (and of a `valueFrom`), read up to the first `.` or `[`; never indexed, never alone. **An `if` gate's one root, `input`, is not a public name** — section 19 |
1144
+
1145
+ ```ts run
1146
+ import { KEY_PATH_RE, keyPathSteps, PREDICATE_ROOTS, readKeyPath, type KeyPathStep } from 'fyflow-orchestration';
1147
+
1148
+ const value = { files: [{ name: 'a.csv' }, { name: 'b.csv' }], xs: ['p', 'q'] };
1149
+
1150
+ // The grammar, the parse and the read of one path.
1151
+ if (!KEY_PATH_RE.test('files[0].name')) throw new Error('files[0].name is a key path');
1152
+ const steps: KeyPathStep[] = keyPathSteps('files[0].name');
1153
+ if (JSON.stringify(steps) !== '[{"key":"files"},{"index":0},{"key":"name"}]') throw new Error(`steps: ${JSON.stringify(steps)}`);
1154
+ if (readKeyPath(value, 'files[0].name') !== 'a.csv') throw new Error('files[0].name reads a.csv');
1155
+ if (readKeyPath(value, 'files[2].name') !== undefined) throw new Error('past the end is nothing');
1156
+
1157
+ // A plain numeric segment is a KEY, and an array has none: xs.1 is nothing where xs[1] is 'q'.
1158
+ if (readKeyPath(value, 'xs[1]') !== 'q' || readKeyPath(value, 'xs.1') !== undefined) throw new Error('xs.1 is a key');
1159
+
1160
+ // A malformed path is refused by the grammar — and the parse, being total, still answers.
1161
+ for (const bad of ['files[*].name', 'xs[01]', '[0].x', 'a..b']) {
1162
+ if (KEY_PATH_RE.test(bad)) throw new Error(`${bad} is not a key path`);
1163
+ }
1164
+ const first = keyPathSteps('files[*].name')[0]!;
1165
+ if (!('key' in first) || first.key !== 'files[*]') throw new Error('an unspelled segment is one literal key');
1166
+
1167
+ // A comparison's path on an edge: a key path whose first key is a root, with a key after it. NECESSARY, not
1168
+ // sufficient: FD-V43 also holds a `needs.X` path to an X the edge's own `needs` declares (since 0.13.0), which
1169
+ // these names cannot see — `validateDefinition` is the authority on a whole flow.
1170
+ const edgePath = (p: string) => KEY_PATH_RE.test(p) && (PREDICATE_ROOTS as readonly string[]).includes(p.split(/[.[]/)[0]!) && /^[A-Za-z0-9_-]+\./.test(p);
1171
+ if (!edgePath('outcome.result.files[0].size') || edgePath('result.size') || edgePath('outcome[0].x') || edgePath('needs')) {
1172
+ throw new Error('the grammar and the roots, as FD-V43 reads them on a path alone');
1173
+ }
1174
+ ```
1175
+
1176
+ **What is not exported, and why.** No *is this a valid comparison path* function: the grammar and the roots are
1177
+ these two values, and a second spelling of `FD-V43` would be a second thing to hold in step — `validateDefinition`
1178
+ is the authority on a whole flow. Nor the declared-comparison internals `evalLeaf` and `evaluateDeclared`: a
1179
+ `when` is evaluated over a `DecisionContext` by **`evaluateWhen(when, ctx, predicates)`**, which IS public from the root entry
1180
+ (`predicates` a `Map` of the registered names it may call), and the built-in `if` evaluates its `when` over `{ input }` inside a run.
1181
+
1100
1182
  ---
1101
1183
 
1102
1184
  ## 5. Fan-out, join, and which policy to pass
@@ -5387,7 +5469,7 @@ registered:
5387
5469
  |---|---|---|
5388
5470
  | **settings** | `{ when }` — a comparison or a flat `all`/`any` group, section 4's grammar, whose paths start at **`input`**: the value that reached the gate | `{ key, cases }` — `key` a key path as `distinct`'s, `cases` a map from a CASE NAME to a scalar or a non-empty array of scalars |
5389
5471
  | **routes** | `then`, and `else` — the DEFAULT, so an edge with no `route` is the fallback | every case name, then `default` — the DEFAULT. So a case left unwired is refused at load, NAMING the case (`FD-V41`) |
5390
- | **matches** | `when` holds over `{ input }`, by the evaluator a task edge's `when` uses — the same operators, absent rule, `default` and `length` | the case one of whose values is `===` the resolved key: `"1"` and `1` are two values, and a value in two cases is refused at load |
5472
+ | **matches** | `when` holds over `{ input }`, by the evaluator a task edge's `when` uses — the same operators, absent rule, `default` and `length`, and since 0.13.0 a `valueFrom` over `input` too (`input.a` `lt` `valueFrom: "input.b"`; `input.b` absent is no match, so `else`) | the case one of whose values is `===` the resolved key: `"1"` and `1` are two values, and a value in two cases is refused at load |
5391
5473
  | **refused at load (`FD-V40`)** | a registered predicate NAME (a gate has no outcome to hand it), a root other than `input` — or `input` alone — and every `when` defect section 4 refuses, named | a case named `default` or outside the route charset, a value that is not a scalar, an empty array, a value in two cases or twice in one, an empty `cases` |
5392
5474
 
5393
5475
  - **Neither reads the gate's memory**: two branches with one key take the same route at a `switch`, where
@@ -5640,8 +5722,9 @@ is underneath it.**
5640
5722
  library's own sub-path — `import { jsonata } from 'fyflow-orchestration/jsonata'` — so it is one
5641
5723
  import and one object entry with no logic in it, but it is not nothing and it is not a domain
5642
5724
  worker. **A flow that only SELECTS needs no adapter; a flow that COMPUTES does.**
5643
- 2. **A comparison reads ONE path and compares it to ONE literal.** It cannot name a second path and
5644
- it cannot do arithmetic, so *is the mean below the threshold this run carries* is not a comparison.
5725
+ 2. **A comparison reads ONE path and compares it to ONE literal — or, since 0.13.0, to ONE other path
5726
+ (`valueFrom`).** It cannot do arithmetic, so *is the threshold this run carries* is a comparison now
5727
+ (`valueFrom: "needs.cfg.threshold"`, the edge declaring `cfg`) and *is the MEAN below it* is not.
5645
5728
  **The route around it is data**: a tier-three `Transform` node computes the answer and the `when`
5646
5729
  reads `outcome.result.<field>`. That is what the flow below does, and it is the one place the
5647
5730
  three-tier rule's *reshape for … a decision* is load-bearing rather than advisory — a `transform`
@@ -5651,7 +5734,8 @@ is underneath it.**
5651
5734
  > **A comparison whose `value` is meant to be a path is NOT refused** — `{ "op": "eq", "value":
5652
5735
  > "needs.cfg.threshold" }` is a perfectly good string comparison against the literal text, and it is
5653
5736
  > false for ever. The grammar catches a wrong ROOT (`FD-V43`) and nothing catches this
5654
- > ([Q456](doc/questions/q456.md)). Write the literal, or compute the comparison in a `Transform` node.
5737
+ > ([Q456](doc/questions/q456.md)). Write the literal — or, since 0.13.0, write the path as `valueFrom`, which is
5738
+ > what reads it.
5655
5739
 
5656
5740
  > **This flow is `examples/flows/ui-emitted.flow.json` and this registry is
5657
5741
  > `examples/ui-registry.json`, with the SCHEMAS reduced to what these three verdicts turn on.** The
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.12.0`.
20
+ Published on npm as `fyflow-orchestration` and on JSR as `@fyflow/orchestration`. The manifests read `0.13.0`.
21
21
 
22
22
  ## Install
23
23
 
@@ -402,7 +402,13 @@ function evalLeaf(scope, p, unknownOp) {
402
402
  else if (typeof v === "string") v = v.length;
403
403
  else return false;
404
404
  }
405
- const w = p.value;
405
+ let w;
406
+ if (p.valueFrom !== void 0) {
407
+ w = readKeyPath(scope, p.valueFrom);
408
+ if (w === void 0 || typeof w === "object" && w !== null) return false;
409
+ } else {
410
+ w = p.value;
411
+ }
406
412
  switch (p.op) {
407
413
  // **Type-STRICT, and it is a departure measured rather than assumed.** Under JS comparison
408
414
  // `{confidence: "0.4"} < 0.5` is true and every `typeof`-guarded predicate in this tree is false;
@@ -755,8 +761,9 @@ function gateTypeCollisions(x) {
755
761
  return names.filter((n) => Object.prototype.hasOwnProperty.call(BUILT_IN_GATE_TYPES, n)).sort();
756
762
  }
757
763
  var PREDICATE_NAME_KEYS = ["predicate"];
758
- var PREDICATE_LEAF_KEYS = ["path", "op", "value", "default", "length"];
759
- var PREDICATE_LEAF_REQUIRED = ["path", "op", "value"];
764
+ var PREDICATE_LEAF_KEYS = ["path", "op", "value", "valueFrom", "default", "length"];
765
+ var PREDICATE_LEAF_REQUIRED = ["path", "op"];
766
+ var RIGHT_SIDE_KEYS = ["value", "valueFrom"];
760
767
  var PREDICATE_GROUP_KEYS = ["all", "any"];
761
768
  function predicateShapeOf(o) {
762
769
  const marks = [];
@@ -783,29 +790,53 @@ var GATE_SCOPE = {
783
790
  foreignRoot: () => 'which an if gate cannot see \u2014 its one root is "input", the value that reached the gate; a gate has no outcome and no edge needs, and any other first segment would resolve to nothing in silence',
784
791
  rootAlone: () => "and names no key \u2014 a gate's path names a key INSIDE the value that reached it, as every gate's key path does; a value that must be compared whole is wrapped by the task before the gate"
785
792
  };
786
- function leafProblem(o, where, scope = EDGE_SCOPE) {
787
- const extra = Object.keys(o).filter((k) => !PREDICATE_LEAF_KEYS.includes(k));
788
- if (extra.length) {
789
- return { rule: "FD-V42", message: `${where} carries ${extra.map((k) => JSON.stringify(k)).join(", ")}, which a comparison does not declare; it is { ${PREDICATE_LEAF_KEYS.join(", ")} }` };
790
- }
791
- for (const k of PREDICATE_LEAF_REQUIRED) {
792
- if (!(k in o)) return { rule: "FD-V42", message: `${where} is missing required key "${k}"; a comparison is { ${PREDICATE_LEAF_KEYS.join(", ")} } with ${PREDICATE_LEAF_REQUIRED.join(", ")} required` };
793
- }
794
- const path = o.path;
793
+ function comparisonPathProblem(o, key, where, scope) {
794
+ const path = o[key];
795
795
  if (typeof path !== "string" || !KEY_PATH_RE.test(path)) {
796
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} is not a key path \u2014 ${KEY_PATH_WORDS}` };
796
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} is not a key path \u2014 ${KEY_PATH_WORDS}` };
797
797
  }
798
- const first = path.split(".")[0];
798
+ const segments = path.split(".");
799
+ const first = segments[0];
799
800
  const bracket = first.indexOf("[");
800
801
  const root = bracket === -1 ? first : first.slice(0, bracket);
801
802
  if (!scope.roots.includes(root)) {
802
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} starts at ${JSON.stringify(root)}, ${scope.foreignRoot(root)}` };
803
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} starts at ${JSON.stringify(root)}, ${scope.foreignRoot(root)}` };
803
804
  }
804
805
  if (bracket !== -1) {
805
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} indexes its root ${JSON.stringify(root)} \u2014 a path names a KEY inside its root first, as a gate's key path names a key inside the value that reached it; an index follows a key (${root}.items[0])` };
806
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} indexes its root ${JSON.stringify(root)} \u2014 a path names a KEY inside its root first, as a gate's key path names a key inside the value that reached it; an index follows a key (${root}.items[0])` };
807
+ }
808
+ if (segments.length < 2) {
809
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} is a root alone ${scope.rootAlone(root)}` };
810
+ }
811
+ if (root === "needs" && scope.needsDeclared !== void 0) {
812
+ const seg = segments[1];
813
+ const at = seg.indexOf("[");
814
+ const name = at === -1 ? seg : seg.slice(0, at);
815
+ if (!scope.needsDeclared.includes(name)) {
816
+ const declared = scope.needsDeclared.length === 0 ? "declares no `needs`, so its `ctx.needs` is {}" : `declares needs ${scope.needsDeclared.map((k2) => JSON.stringify(k2)).join(", ")}`;
817
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} reads needs key ${JSON.stringify(name)}, and this edge ${declared} \u2014 the key would be absent for ever and the comparison no match for ever; declare it in the edge's needs` };
818
+ }
819
+ }
820
+ return null;
821
+ }
822
+ function leafProblem(o, where, scope = EDGE_SCOPE) {
823
+ const extra = Object.keys(o).filter((k) => !PREDICATE_LEAF_KEYS.includes(k));
824
+ if (extra.length) {
825
+ return { rule: "FD-V42", message: `${where} carries ${extra.map((k) => JSON.stringify(k)).join(", ")}, which a comparison does not declare; it is { ${PREDICATE_LEAF_KEYS.join(", ")} }` };
806
826
  }
807
- if (path.split(".").length < 2) {
808
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} is a root alone ${scope.rootAlone(root)}` };
827
+ for (const k of PREDICATE_LEAF_REQUIRED) {
828
+ if (!(k in o)) return { rule: "FD-V42", message: `${where} is missing required key "${k}"; a comparison is { ${PREDICATE_LEAF_KEYS.join(", ")} } with ${PREDICATE_LEAF_REQUIRED.join(", ")} required and exactly one of ${RIGHT_SIDE_KEYS.map((k2) => JSON.stringify(k2)).join(" or ")}` };
829
+ }
830
+ const sides = RIGHT_SIDE_KEYS.filter((k) => k in o);
831
+ if (sides.length !== 1) {
832
+ return { rule: "FD-V42", message: `${where} carries ${sides.length === 0 ? "neither" : "both"} "value" and "valueFrom"; a comparison compares against exactly one \u2014 a literal "value", or "valueFrom", a key path over the same context` };
833
+ }
834
+ const pathProblem = comparisonPathProblem(o, "path", where, scope);
835
+ if (pathProblem !== null) return pathProblem;
836
+ const fromValue = "valueFrom" in o;
837
+ if (fromValue) {
838
+ const fromProblem = comparisonPathProblem(o, "valueFrom", where, scope);
839
+ if (fromProblem !== null) return fromProblem;
809
840
  }
810
841
  const op = o.op;
811
842
  if (typeof op !== "string" || !PREDICATE_OPS.includes(op)) {
@@ -815,6 +846,12 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
815
846
  return { rule: "FD-V44", message: `${where} length is ${JSON.stringify(o.length)}; it is exactly true or absent \u2014 there is no "compare the value, not its length" to ask for` };
816
847
  }
817
848
  const takesLength = o.length === true;
849
+ if (fromValue) {
850
+ if (takesLength && STRING_OPS.has(op)) {
851
+ return { rule: "FD-V44", message: `${where} takes a length and uses "${op}"; a length is a number and has no prefix` };
852
+ }
853
+ return defaultProblem(o, where, takesLength);
854
+ }
818
855
  if (!isScalar(o.value)) {
819
856
  return { rule: "FD-V44", message: `${where} value is ${scalarName(o.value)}; a comparison compares against a SCALAR \u2014 null, a boolean, a finite number or a string` };
820
857
  }
@@ -830,6 +867,9 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
830
867
  if (takesLength && STRING_OPS.has(op)) {
831
868
  return { rule: "FD-V44", message: `${where} takes a length and uses "${op}"; a length is a number and has no prefix` };
832
869
  }
870
+ return defaultProblem(o, where, takesLength);
871
+ }
872
+ function defaultProblem(o, where, takesLength) {
833
873
  if ("default" in o) {
834
874
  const d = o.default;
835
875
  const okDefault = Array.isArray(d) ? d.every(isScalar) : isScalar(d);
@@ -845,7 +885,8 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
845
885
  }
846
886
  return null;
847
887
  }
848
- function predicateProblem(v) {
888
+ function predicateProblem(v, edgeNeeds) {
889
+ const scope = edgeNeeds === void 0 ? EDGE_SCOPE : { ...EDGE_SCOPE, needsDeclared: edgeNeeds };
849
890
  if (typeof v !== "object" || v === null || Array.isArray(v)) {
850
891
  return {
851
892
  rule: "FD-V42",
@@ -866,8 +907,8 @@ function predicateProblem(v) {
866
907
  if (typeof o.predicate !== "string") return { rule: "FD-V42", message: `when predicate is ${scalarName(o.predicate)}; it is the name of a registered predicate` };
867
908
  return null;
868
909
  }
869
- if (shape === "leaf") return leafProblem(o, "when");
870
- return groupProblem(o, "when", EDGE_SCOPE);
910
+ if (shape === "leaf") return leafProblem(o, "when", scope);
911
+ return groupProblem(o, "when", scope);
871
912
  }
872
913
  function groupProblem(o, label, scope) {
873
914
  const extra = Object.keys(o).filter((k) => !PREDICATE_GROUP_KEYS.includes(k));
@@ -1360,7 +1401,9 @@ function validateDefinition(doc, registry) {
1360
1401
  }
1361
1402
  }
1362
1403
  if (ed?.when !== void 0) {
1363
- const problem = predicateProblem(ed.when);
1404
+ const nv = ed.needs;
1405
+ const edgeNeeds = nv === void 0 ? [] : typeof nv === "object" && nv !== null && !Array.isArray(nv) && Object.keys(nv).length > 0 ? Object.keys(nv) : void 0;
1406
+ const problem = predicateProblem(ed.when, edgeNeeds);
1364
1407
  if (problem) add(problem.rule, at, problem.message);
1365
1408
  else for (const name of predicateNamesIn(ed.when)) {
1366
1409
  if (!preds.has(name)) add("FD-V4", at, `when names "${name}", which is not a registered predicate`);
@@ -7028,6 +7071,7 @@ export {
7028
7071
  canonical,
7029
7072
  findings,
7030
7073
  describeFindings,
7074
+ keyPathSteps,
7031
7075
  readKeyPath,
7032
7076
  ID_RE_COPY,
7033
7077
  KINDS_COPY,
@@ -111,6 +111,7 @@ import {
111
111
  isTerminal,
112
112
  isTerminalEvent,
113
113
  keyPathProblem,
114
+ keyPathSteps,
114
115
  languagesOf,
115
116
  lastRecordFor,
116
117
  liveToken,
@@ -151,7 +152,7 @@ import {
151
152
  visibilityKey,
152
153
  witness,
153
154
  workersOf
154
- } from "./chunks/chunk-4JFQX7DL.js";
155
+ } from "./chunks/chunk-53EDI674.js";
155
156
 
156
157
  // src/definition/definition-schema.ts
157
158
  var DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
@@ -237,7 +238,7 @@ var KIND_DESCRIPTIONS = {
237
238
  var EDGE_DESCRIPTIONS = {
238
239
  to: "The id of the node this edge leads to (FD-V2, validateDefinition's).",
239
240
  on: 'The terminal states this edge is eligible for: a non-empty list of distinct "done" / "failed" (FD-V18). Absent: ["done"]. "user_action" suspends rather than routes.',
240
- when: "The decision: a registered predicate NAME { predicate }, a declared COMPARISON { path, op, value, default?, length? }, or a flat GROUP { all } or { any } of comparisons (FD-V42-FD-V45). Absent: the edge is the default for its state.",
241
+ when: "The decision: a registered predicate NAME { predicate }, a declared COMPARISON { path, op, value or valueFrom, default?, length? }, or a flat GROUP { all } or { any } of comparisons (FD-V42-FD-V45). Absent: the edge is the default for its state.",
241
242
  needs: "What this edge's predicate sees beyond its node's outcome \u2014 `needs`' grammar minus \".\" (FD-V26).",
242
243
  route: "On a gate's edge, which of the gate type's routes it carries; absent: the type's DEFAULT route (FD-V41). Refused on every other kind's edge."
243
244
  };
@@ -251,9 +252,10 @@ var TRANSFORM_DESCRIPTIONS = {
251
252
  };
252
253
  var PREDICATE_DESCRIPTIONS = {
253
254
  predicate: "A registered predicate's name (FD-V4, validateDefinition's).",
254
- path: `A key path over the decision's context, starting at ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")} and naming a key beyond it; a key past the root may be followed by array indexes [n], the root never (FD-V43).`,
255
+ path: `A key path over the decision's context, starting at ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")} and naming a key beyond it; a key past the root may be followed by array indexes [n], the root never; a "needs" key the edge declares (FD-V43).`,
255
256
  op: `One of ${PREDICATE_OPS.join(", ")} (FD-V44). The ordering operators compare numbers and "startsWith" a string \u2014 a comparison between kinds is no match, never a coercion.`,
256
- value: "The scalar compared against: null, a boolean, a number or a string (FD-V44).",
257
+ value: "The scalar compared against: null, a boolean, a number or a string (FD-V44). Exactly one of value and valueFrom (FD-V42).",
258
+ valueFrom: `A key path over the same context, compared against as a literal would be \u2014 the same roots as path, ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")}, and a "needs" key the edge declares (FD-V43). Exactly one of value and valueFrom (FD-V42). Where it does not resolve, or resolves to an object or an array, the comparison is no match for every operator.`,
257
259
  default: "What is compared where the path does not resolve or resolves to null: a scalar, or \u2014 with `length` \u2014 an array or a string (FD-V44).",
258
260
  length: "Exactly true: compare the LENGTH of the array or string, against a number (FD-V44).",
259
261
  all: "A flat group: a non-empty list of comparisons that must all hold (FD-V45).",
@@ -438,6 +440,9 @@ function leafSchema(id) {
438
440
  path: { type: "string", pattern: path },
439
441
  op: { enum: [...PREDICATE_OPS] },
440
442
  value: scalar(),
443
+ // v0.55 (ADR 0140): the RIGHT side as a key path — `path`'s own pattern, so the two keys cannot be held to different
444
+ // grammars; (g1)'s *a `needs` key the edge declares* needs the edge's own `needs` and is the validator's alone.
445
+ valueFrom: { type: "string", pattern: path },
441
446
  default: { anyOf: [scalar(), { type: "array", items: scalar() }] },
442
447
  length: { const: true }
443
448
  };
@@ -445,6 +450,8 @@ function leafSchema(id) {
445
450
  description: "A declared comparison over the decision's context.",
446
451
  type: "object",
447
452
  required: [...PREDICATE_LEAF_REQUIRED],
453
+ // **Exactly one of `value` / `valueFrom`** (`FD-V42`, v0.55): both satisfy both branches and neither satisfies none.
454
+ oneOf: [{ required: ["value"] }, { required: ["valueFrom"] }],
448
455
  properties: Object.fromEntries(PREDICATE_LEAF_KEYS.map((k) => {
449
456
  const s = keySchemas[k];
450
457
  if (s === void 0) throw new Error(`definitionSchema: a comparison declares the key "${k}" and no schema is written for it`);
@@ -942,6 +949,7 @@ export {
942
949
  isTerminal,
943
950
  isTerminalEvent,
944
951
  keyPathProblem,
952
+ keyPathSteps,
945
953
  languagesOf,
946
954
  lastRecordFor,
947
955
  liveToken,
@@ -26,7 +26,7 @@ import {
26
26
  retryPolicyProblem,
27
27
  validateDefinition,
28
28
  workersOf
29
- } from "../chunks/chunk-4JFQX7DL.js";
29
+ } from "../chunks/chunk-53EDI674.js";
30
30
 
31
31
  // src/scheduler/bootstrap.ts
32
32
  import { WorkerManager } from "fyflow-scheduler";
@@ -402,7 +402,13 @@ function evalLeaf(scope, p, unknownOp) {
402
402
  else if (typeof v === "string") v = v.length;
403
403
  else return false;
404
404
  }
405
- const w = p.value;
405
+ let w;
406
+ if (p.valueFrom !== void 0) {
407
+ w = readKeyPath(scope, p.valueFrom);
408
+ if (w === void 0 || typeof w === "object" && w !== null) return false;
409
+ } else {
410
+ w = p.value;
411
+ }
406
412
  switch (p.op) {
407
413
  // **Type-STRICT, and it is a departure measured rather than assumed.** Under JS comparison
408
414
  // `{confidence: "0.4"} < 0.5` is true and every `typeof`-guarded predicate in this tree is false;
@@ -755,8 +761,9 @@ function gateTypeCollisions(x) {
755
761
  return names.filter((n) => Object.prototype.hasOwnProperty.call(BUILT_IN_GATE_TYPES, n)).sort();
756
762
  }
757
763
  var PREDICATE_NAME_KEYS = ["predicate"];
758
- var PREDICATE_LEAF_KEYS = ["path", "op", "value", "default", "length"];
759
- var PREDICATE_LEAF_REQUIRED = ["path", "op", "value"];
764
+ var PREDICATE_LEAF_KEYS = ["path", "op", "value", "valueFrom", "default", "length"];
765
+ var PREDICATE_LEAF_REQUIRED = ["path", "op"];
766
+ var RIGHT_SIDE_KEYS = ["value", "valueFrom"];
760
767
  var PREDICATE_GROUP_KEYS = ["all", "any"];
761
768
  function predicateShapeOf(o) {
762
769
  const marks = [];
@@ -783,29 +790,53 @@ var GATE_SCOPE = {
783
790
  foreignRoot: () => 'which an if gate cannot see \u2014 its one root is "input", the value that reached the gate; a gate has no outcome and no edge needs, and any other first segment would resolve to nothing in silence',
784
791
  rootAlone: () => "and names no key \u2014 a gate's path names a key INSIDE the value that reached it, as every gate's key path does; a value that must be compared whole is wrapped by the task before the gate"
785
792
  };
786
- function leafProblem(o, where, scope = EDGE_SCOPE) {
787
- const extra = Object.keys(o).filter((k) => !PREDICATE_LEAF_KEYS.includes(k));
788
- if (extra.length) {
789
- return { rule: "FD-V42", message: `${where} carries ${extra.map((k) => JSON.stringify(k)).join(", ")}, which a comparison does not declare; it is { ${PREDICATE_LEAF_KEYS.join(", ")} }` };
790
- }
791
- for (const k of PREDICATE_LEAF_REQUIRED) {
792
- if (!(k in o)) return { rule: "FD-V42", message: `${where} is missing required key "${k}"; a comparison is { ${PREDICATE_LEAF_KEYS.join(", ")} } with ${PREDICATE_LEAF_REQUIRED.join(", ")} required` };
793
- }
794
- const path = o.path;
793
+ function comparisonPathProblem(o, key, where, scope) {
794
+ const path = o[key];
795
795
  if (typeof path !== "string" || !KEY_PATH_RE.test(path)) {
796
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} is not a key path \u2014 ${KEY_PATH_WORDS}` };
796
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} is not a key path \u2014 ${KEY_PATH_WORDS}` };
797
797
  }
798
- const first = path.split(".")[0];
798
+ const segments = path.split(".");
799
+ const first = segments[0];
799
800
  const bracket = first.indexOf("[");
800
801
  const root = bracket === -1 ? first : first.slice(0, bracket);
801
802
  if (!scope.roots.includes(root)) {
802
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} starts at ${JSON.stringify(root)}, ${scope.foreignRoot(root)}` };
803
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} starts at ${JSON.stringify(root)}, ${scope.foreignRoot(root)}` };
803
804
  }
804
805
  if (bracket !== -1) {
805
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} indexes its root ${JSON.stringify(root)} \u2014 a path names a KEY inside its root first, as a gate's key path names a key inside the value that reached it; an index follows a key (${root}.items[0])` };
806
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} indexes its root ${JSON.stringify(root)} \u2014 a path names a KEY inside its root first, as a gate's key path names a key inside the value that reached it; an index follows a key (${root}.items[0])` };
807
+ }
808
+ if (segments.length < 2) {
809
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} is a root alone ${scope.rootAlone(root)}` };
810
+ }
811
+ if (root === "needs" && scope.needsDeclared !== void 0) {
812
+ const seg = segments[1];
813
+ const at = seg.indexOf("[");
814
+ const name = at === -1 ? seg : seg.slice(0, at);
815
+ if (!scope.needsDeclared.includes(name)) {
816
+ const declared = scope.needsDeclared.length === 0 ? "declares no `needs`, so its `ctx.needs` is {}" : `declares needs ${scope.needsDeclared.map((k2) => JSON.stringify(k2)).join(", ")}`;
817
+ return { rule: "FD-V43", message: `${where} ${key} ${JSON.stringify(path)} reads needs key ${JSON.stringify(name)}, and this edge ${declared} \u2014 the key would be absent for ever and the comparison no match for ever; declare it in the edge's needs` };
818
+ }
819
+ }
820
+ return null;
821
+ }
822
+ function leafProblem(o, where, scope = EDGE_SCOPE) {
823
+ const extra = Object.keys(o).filter((k) => !PREDICATE_LEAF_KEYS.includes(k));
824
+ if (extra.length) {
825
+ return { rule: "FD-V42", message: `${where} carries ${extra.map((k) => JSON.stringify(k)).join(", ")}, which a comparison does not declare; it is { ${PREDICATE_LEAF_KEYS.join(", ")} }` };
806
826
  }
807
- if (path.split(".").length < 2) {
808
- return { rule: "FD-V43", message: `${where} path ${JSON.stringify(path)} is a root alone ${scope.rootAlone(root)}` };
827
+ for (const k of PREDICATE_LEAF_REQUIRED) {
828
+ if (!(k in o)) return { rule: "FD-V42", message: `${where} is missing required key "${k}"; a comparison is { ${PREDICATE_LEAF_KEYS.join(", ")} } with ${PREDICATE_LEAF_REQUIRED.join(", ")} required and exactly one of ${RIGHT_SIDE_KEYS.map((k2) => JSON.stringify(k2)).join(" or ")}` };
829
+ }
830
+ const sides = RIGHT_SIDE_KEYS.filter((k) => k in o);
831
+ if (sides.length !== 1) {
832
+ return { rule: "FD-V42", message: `${where} carries ${sides.length === 0 ? "neither" : "both"} "value" and "valueFrom"; a comparison compares against exactly one \u2014 a literal "value", or "valueFrom", a key path over the same context` };
833
+ }
834
+ const pathProblem = comparisonPathProblem(o, "path", where, scope);
835
+ if (pathProblem !== null) return pathProblem;
836
+ const fromValue = "valueFrom" in o;
837
+ if (fromValue) {
838
+ const fromProblem = comparisonPathProblem(o, "valueFrom", where, scope);
839
+ if (fromProblem !== null) return fromProblem;
809
840
  }
810
841
  const op = o.op;
811
842
  if (typeof op !== "string" || !PREDICATE_OPS.includes(op)) {
@@ -815,6 +846,12 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
815
846
  return { rule: "FD-V44", message: `${where} length is ${JSON.stringify(o.length)}; it is exactly true or absent \u2014 there is no "compare the value, not its length" to ask for` };
816
847
  }
817
848
  const takesLength = o.length === true;
849
+ if (fromValue) {
850
+ if (takesLength && STRING_OPS.has(op)) {
851
+ return { rule: "FD-V44", message: `${where} takes a length and uses "${op}"; a length is a number and has no prefix` };
852
+ }
853
+ return defaultProblem(o, where, takesLength);
854
+ }
818
855
  if (!isScalar(o.value)) {
819
856
  return { rule: "FD-V44", message: `${where} value is ${scalarName(o.value)}; a comparison compares against a SCALAR \u2014 null, a boolean, a finite number or a string` };
820
857
  }
@@ -830,6 +867,9 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
830
867
  if (takesLength && STRING_OPS.has(op)) {
831
868
  return { rule: "FD-V44", message: `${where} takes a length and uses "${op}"; a length is a number and has no prefix` };
832
869
  }
870
+ return defaultProblem(o, where, takesLength);
871
+ }
872
+ function defaultProblem(o, where, takesLength) {
833
873
  if ("default" in o) {
834
874
  const d = o.default;
835
875
  const okDefault = Array.isArray(d) ? d.every(isScalar) : isScalar(d);
@@ -845,7 +885,8 @@ function leafProblem(o, where, scope = EDGE_SCOPE) {
845
885
  }
846
886
  return null;
847
887
  }
848
- function predicateProblem(v) {
888
+ function predicateProblem(v, edgeNeeds) {
889
+ const scope = edgeNeeds === void 0 ? EDGE_SCOPE : { ...EDGE_SCOPE, needsDeclared: edgeNeeds };
849
890
  if (typeof v !== "object" || v === null || Array.isArray(v)) {
850
891
  return {
851
892
  rule: "FD-V42",
@@ -866,8 +907,8 @@ function predicateProblem(v) {
866
907
  if (typeof o.predicate !== "string") return { rule: "FD-V42", message: `when predicate is ${scalarName(o.predicate)}; it is the name of a registered predicate` };
867
908
  return null;
868
909
  }
869
- if (shape === "leaf") return leafProblem(o, "when");
870
- return groupProblem(o, "when", EDGE_SCOPE);
910
+ if (shape === "leaf") return leafProblem(o, "when", scope);
911
+ return groupProblem(o, "when", scope);
871
912
  }
872
913
  function groupProblem(o, label, scope) {
873
914
  const extra = Object.keys(o).filter((k) => !PREDICATE_GROUP_KEYS.includes(k));
@@ -1360,7 +1401,9 @@ function validateDefinition(doc, registry) {
1360
1401
  }
1361
1402
  }
1362
1403
  if (ed?.when !== void 0) {
1363
- const problem = predicateProblem(ed.when);
1404
+ const nv = ed.needs;
1405
+ const edgeNeeds = nv === void 0 ? [] : typeof nv === "object" && nv !== null && !Array.isArray(nv) && Object.keys(nv).length > 0 ? Object.keys(nv) : void 0;
1406
+ const problem = predicateProblem(ed.when, edgeNeeds);
1364
1407
  if (problem) add(problem.rule, at, problem.message);
1365
1408
  else for (const name of predicateNamesIn(ed.when)) {
1366
1409
  if (!preds.has(name)) add("FD-V4", at, `when names "${name}", which is not a registered predicate`);
@@ -7028,6 +7071,7 @@ export {
7028
7071
  canonical,
7029
7072
  findings,
7030
7073
  describeFindings,
7074
+ keyPathSteps,
7031
7075
  readKeyPath,
7032
7076
  ID_RE_COPY,
7033
7077
  KINDS_COPY,
@@ -111,6 +111,7 @@ import {
111
111
  isTerminal,
112
112
  isTerminalEvent,
113
113
  keyPathProblem,
114
+ keyPathSteps,
114
115
  languagesOf,
115
116
  lastRecordFor,
116
117
  liveToken,
@@ -151,7 +152,7 @@ import {
151
152
  visibilityKey,
152
153
  witness,
153
154
  workersOf
154
- } from "./chunks/chunk-4JFQX7DL.js";
155
+ } from "./chunks/chunk-53EDI674.js";
155
156
 
156
157
  // src/definition/definition-schema.ts
157
158
  var DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
@@ -237,7 +238,7 @@ var KIND_DESCRIPTIONS = {
237
238
  var EDGE_DESCRIPTIONS = {
238
239
  to: "The id of the node this edge leads to (FD-V2, validateDefinition's).",
239
240
  on: 'The terminal states this edge is eligible for: a non-empty list of distinct "done" / "failed" (FD-V18). Absent: ["done"]. "user_action" suspends rather than routes.',
240
- when: "The decision: a registered predicate NAME { predicate }, a declared COMPARISON { path, op, value, default?, length? }, or a flat GROUP { all } or { any } of comparisons (FD-V42-FD-V45). Absent: the edge is the default for its state.",
241
+ when: "The decision: a registered predicate NAME { predicate }, a declared COMPARISON { path, op, value or valueFrom, default?, length? }, or a flat GROUP { all } or { any } of comparisons (FD-V42-FD-V45). Absent: the edge is the default for its state.",
241
242
  needs: "What this edge's predicate sees beyond its node's outcome \u2014 `needs`' grammar minus \".\" (FD-V26).",
242
243
  route: "On a gate's edge, which of the gate type's routes it carries; absent: the type's DEFAULT route (FD-V41). Refused on every other kind's edge."
243
244
  };
@@ -251,9 +252,10 @@ var TRANSFORM_DESCRIPTIONS = {
251
252
  };
252
253
  var PREDICATE_DESCRIPTIONS = {
253
254
  predicate: "A registered predicate's name (FD-V4, validateDefinition's).",
254
- path: `A key path over the decision's context, starting at ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")} and naming a key beyond it; a key past the root may be followed by array indexes [n], the root never (FD-V43).`,
255
+ path: `A key path over the decision's context, starting at ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")} and naming a key beyond it; a key past the root may be followed by array indexes [n], the root never; a "needs" key the edge declares (FD-V43).`,
255
256
  op: `One of ${PREDICATE_OPS.join(", ")} (FD-V44). The ordering operators compare numbers and "startsWith" a string \u2014 a comparison between kinds is no match, never a coercion.`,
256
- value: "The scalar compared against: null, a boolean, a number or a string (FD-V44).",
257
+ value: "The scalar compared against: null, a boolean, a number or a string (FD-V44). Exactly one of value and valueFrom (FD-V42).",
258
+ valueFrom: `A key path over the same context, compared against as a literal would be \u2014 the same roots as path, ${PREDICATE_ROOTS.map((r) => `"${r}"`).join(" or ")}, and a "needs" key the edge declares (FD-V43). Exactly one of value and valueFrom (FD-V42). Where it does not resolve, or resolves to an object or an array, the comparison is no match for every operator.`,
257
259
  default: "What is compared where the path does not resolve or resolves to null: a scalar, or \u2014 with `length` \u2014 an array or a string (FD-V44).",
258
260
  length: "Exactly true: compare the LENGTH of the array or string, against a number (FD-V44).",
259
261
  all: "A flat group: a non-empty list of comparisons that must all hold (FD-V45).",
@@ -438,6 +440,9 @@ function leafSchema(id) {
438
440
  path: { type: "string", pattern: path },
439
441
  op: { enum: [...PREDICATE_OPS] },
440
442
  value: scalar(),
443
+ // v0.55 (ADR 0140): the RIGHT side as a key path — `path`'s own pattern, so the two keys cannot be held to different
444
+ // grammars; (g1)'s *a `needs` key the edge declares* needs the edge's own `needs` and is the validator's alone.
445
+ valueFrom: { type: "string", pattern: path },
441
446
  default: { anyOf: [scalar(), { type: "array", items: scalar() }] },
442
447
  length: { const: true }
443
448
  };
@@ -445,6 +450,8 @@ function leafSchema(id) {
445
450
  description: "A declared comparison over the decision's context.",
446
451
  type: "object",
447
452
  required: [...PREDICATE_LEAF_REQUIRED],
453
+ // **Exactly one of `value` / `valueFrom`** (`FD-V42`, v0.55): both satisfy both branches and neither satisfies none.
454
+ oneOf: [{ required: ["value"] }, { required: ["valueFrom"] }],
448
455
  properties: Object.fromEntries(PREDICATE_LEAF_KEYS.map((k) => {
449
456
  const s = keySchemas[k];
450
457
  if (s === void 0) throw new Error(`definitionSchema: a comparison declares the key "${k}" and no schema is written for it`);
@@ -942,6 +949,7 @@ export {
942
949
  isTerminal,
943
950
  isTerminalEvent,
944
951
  keyPathProblem,
952
+ keyPathSteps,
945
953
  languagesOf,
946
954
  lastRecordFor,
947
955
  liveToken,
@@ -26,7 +26,7 @@ import {
26
26
  retryPolicyProblem,
27
27
  validateDefinition,
28
28
  workersOf
29
- } from "../chunks/chunk-4JFQX7DL.js";
29
+ } from "../chunks/chunk-53EDI674.js";
30
30
 
31
31
  // src/scheduler/bootstrap.ts
32
32
  import { WorkerManager } from "fyflow-scheduler";
@@ -6,9 +6,11 @@
6
6
  * is*). [W154](../../doc/work-items/w154-the-built-in-if-and-switch.md) exception (p): until it, the `if` reached
7
7
  * the evaluator through `routing.ts`, which imports `src/definition` — a module cycle in a published library.
8
8
  *
9
- * **Not re-exported by `index.ts`.** `readKeyPath` stays public through `validate.ts`' re-export, and
10
- * `evaluateWhen` through `routing.ts`, so the package's exported names do not move. Nothing here imports anything
11
- * but types.
9
+ * **Not re-exported WHOLE by `index.ts`.** Three names here are public, each through `validate.ts`' re-export:
10
+ * `readKeyPath` (public since W92, defined in `validate.ts` then; through this re-export since W154), and `keyPathSteps`
11
+ * with its `KeyPathStep` (since W220, P45 (c1) — fyflow-app's pass 6 #2: a host reads a key path with the library's own
12
+ * parse and read rather than a copy of the spelling). `evalLeaf` and `evaluateDeclared` are NOT (P45 decision 1): a
13
+ * host evaluates a `when` by `evaluateWhen`, public through `routing.ts`. Nothing here imports anything but types.
12
14
  */
13
15
  import type { PredicateLeaf, When } from './types.js';
14
16
  /**
@@ -52,7 +54,8 @@ export declare function readKeyPath(value: unknown, path: string): unknown;
52
54
  * closure over anything — so a declared predicate **CANNOT** be impure, which is strictly stronger
53
55
  * than [Q9](../../doc/questions/q9.md)'s measured result for a function (evaluating twice caught
54
56
  * 1 impurity shape of 4) and is asserted rather than argued. `scope` is a task edge's `DecisionContext`
55
- * or an `if` gate's `{ input }` (W154) — the path is read off it by `readKeyPath`, and that is the only read.
57
+ * or an `if` gate's `{ input }` (W154) — the path, and since v0.55 a `valueFrom`, is read off it by `readKeyPath`,
58
+ * and that is the only read.
56
59
  *
57
60
  * **Total by construction**, because `FD-V42`-`FD-V45` refuse everything this does not handle at
58
61
  * `new Driver`. The `default:` arm below is the assertion that they held; `unknownOp` is the error it throws,
@@ -86,10 +86,12 @@ export type PredicateOp = typeof PREDICATE_OPS[number];
86
86
  /** The two roots a predicate's key path may start at — the two members of `DecisionContext`, and no other. */
87
87
  export declare const PREDICATE_ROOTS: readonly ["outcome", "needs"];
88
88
  /**
89
- * **What a declared comparison may compare AGAINST** — a scalar, and nothing else (`FD-V44`).
89
+ * **What a declared comparison's LITERAL may be** — a scalar, and nothing else (`FD-V44`).
90
90
  * Every one of the ten predicate bodies in this tree compares to one (`0.5`, `50`, `true`,
91
91
  * `'r1:publish:'`, `0`), and admitting an object would need a deep-equality semantics with no
92
- * instance and no obvious right answer for `NaN`, key order or a prototype.
92
+ * instance and no obvious right answer for `NaN`, key order or a prototype. *Since v0.55 a comparison
93
+ * may compare against ANOTHER key path instead — `valueFrom` (ADR 0140) — and a resolved object or
94
+ * array there is no match, for the same reason.*
93
95
  */
94
96
  export type PredicateScalar = null | boolean | number | string;
95
97
  /** A registered predicate NAME, resolved against the registry at instantiation (`FD-V4`). */
@@ -97,21 +99,20 @@ export interface PredicateName {
97
99
  predicate: string;
98
100
  }
99
101
  /**
100
- * **One comparison over the decision's own context** — `FD-V43` owns the path and `FD-V44` the
101
- * operator and the value.
102
+ * **What every comparison carries, whichever right side it names** — `FD-V43` owns the path and `FD-V44` the
103
+ * operator and the modifiers.
102
104
  */
103
- export interface PredicateLeaf {
105
+ export interface PredicateLeafBase {
104
106
  /**
105
107
  * A KEY PATH over the `DecisionContext` itself, read by `readKeyPath` — the same resolution a
106
108
  * gate's `settings.key` gets, measured unchanged at this scope. **Its first segment is `outcome`
107
109
  * or `needs` and nothing else** (`FD-V43`), because a first segment that is neither resolves to
108
110
  * `undefined` in silence and a typo would be a wrong key rather than a refusal. A key past the root may
109
111
  * carry array indexes — `outcome.result.items[0].score` — and the root never does (v0.53, ADR 0137).
112
+ * **A `needs` path names a key that edge's `needs` DECLARES** (v0.55, P45 (g1)): any other is absent for ever.
110
113
  */
111
114
  path: string;
112
115
  op: PredicateOp;
113
- /** The literal compared against. Required — there is no operator here that takes one operand. */
114
- value: PredicateScalar;
115
116
  /**
116
117
  * **What is compared when the path does not resolve, or resolves to `null`.** Absent is NO MATCH
117
118
  * by default; where this is declared it is substituted instead — which is `?? 0` and `?? []`
@@ -136,6 +137,29 @@ export interface PredicateLeaf {
136
137
  */
137
138
  length?: true;
138
139
  }
140
+ /**
141
+ * **One comparison over the decision's own context — against a LITERAL, or against ANOTHER KEY PATH** (v0.55,
142
+ * [ADR 0140](../../doc/decisions/0140-a-comparison-may-compare-against-another-key-path-and-an-absent-one-is-no-match.md),
143
+ * P45 (a1)). Exactly one of `value` / `valueFrom` (`FD-V42`), so a TWO-member union rather than one interface with
144
+ * both optional — `PredicateGroup`'s precedent: `{ path, op }` with neither, and both at once, fail at L0. **The type
145
+ * change, stated** (P45 decision 8): until 0.13.0 `value` was required, so code READING `leaf.value` now sees
146
+ * `PredicateScalar | undefined` and tells the two apart by `'valueFrom' in leaf`.
147
+ */
148
+ export type PredicateLeaf = (PredicateLeafBase & {
149
+ /** The literal compared against. */
150
+ value: PredicateScalar;
151
+ valueFrom?: never;
152
+ }) | (PredicateLeafBase & {
153
+ /**
154
+ * **A key path over the SAME context, compared against as a literal would be** — the same roots as `path` (and
155
+ * the same `needs` rule), held to `FD-V43`; resolved by `readKeyPath` when the comparison is asked. Type-strict, a
156
+ * resolved `null` compared as `null`; **a resolved object or array is NO MATCH for every operator, `ne` included**;
157
+ * and **an ABSENT one is NO MATCH** — the left side's `default` does not stand in for it, so `x gt y` and `x lte y`
158
+ * are both false where `y` is absent. `default` and `length` are the LEFT side's only.
159
+ */
160
+ valueFrom: string;
161
+ value?: never;
162
+ });
139
163
  /**
140
164
  * **A FLAT group — `all` or `any`, and its members are LEAVES.** Nesting is refused by name
141
165
  * (`FD-V45`), and so is a `{ predicate }` member: a group holding a registered name could be
@@ -18,7 +18,8 @@
18
18
  * ADR 0002 rule 3: this runs in full, at instantiation, before any task is queued.
19
19
  */
20
20
  import type { Finding, FlowDefinition, GateType, GateTypes, JsonSchema, StaticGateType, LanguageAdapter, Languages, NodeTypeEntry, NodeTypes, Pools, Registry, WorkerEntry, Workers } from './types.js';
21
- export { readKeyPath } from './condition.js';
21
+ export { keyPathSteps, readKeyPath } from './condition.js';
22
+ export type { KeyPathStep } from './condition.js';
22
23
  declare const KINDS: readonly ["task", "fanout", "map", "join", "end", "gate", "ask"];
23
24
  /**
24
25
  * **`FD-V16`'s charset — a flow's and a node's id** ([spec/task-id.md](../../spec/task-id.md)'s) — v0.50 (W210,
@@ -269,7 +270,7 @@ export declare const PREDICATE_GROUP_KEYS: readonly string[];
269
270
  * has owned *an edge's `when` names a predicate absent from the registry* since P1 and whose only
270
271
  * change at v0.27 is where the name is written.
271
272
  */
272
- export declare function predicateProblem(v: unknown): {
273
+ export declare function predicateProblem(v: unknown, edgeNeeds?: readonly string[]): {
273
274
  rule: string;
274
275
  message: string;
275
276
  } | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fyflow-orchestration",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Flow orchestration over fyflow-scheduler: sequence, decision, fan-out and join",
5
5
  "keywords": [
6
6
  "workflow",