@nanobpm/nano-workforce 0.130.0 → 0.131.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.
@@ -37,6 +37,7 @@ import type {
37
37
  } from "../nano-generated/api-io.d.ts";
38
38
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
39
39
  import {
40
+ analyzeExclusiveTopology,
40
41
  type DeliveryGraphError,
41
42
  deliveryNodeFacts,
42
43
  resolveDeliveryFrom,
@@ -105,6 +106,15 @@ function escapeXml(value: string): string {
105
106
  .replace(/'/g, "'");
106
107
  }
107
108
 
109
+ /** Escape a string for XML ELEMENT TEXT content while KEEPING literal double-quotes — the convention
110
+ * every authored `<bpmn:conditionExpression>` FEEL uses (e.g. `=status = "converged"`). Only `&`, `<`,
111
+ * `>` are entity-escaped (required for text-node well-formedness); quotes stay literal so a FEEL string
112
+ * literal survives to the engine. Safe because the compiler grafts DI onto its own semantic XML without
113
+ * re-serializing it, so these text nodes are never round-tripped/normalized. Deterministic and total. */
114
+ function escapeXmlText(value: string): string {
115
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
116
+ }
117
+
108
118
  /** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
109
119
  * WASM engine's deploy path. That path does NOT decode `&#34;`/`&quot;` entities before FEEL parsing,
110
120
  * so a FEEL expression containing a string literal MUST use a SINGLE-QUOTE attribute delimiter with
@@ -168,13 +178,44 @@ function feelStr(value: string): string {
168
178
  return JSON.stringify(value);
169
179
  }
170
180
 
181
+ /** Render a guard `equals` literal (S7) as its FEEL form — a string becomes a `"…"` literal, a number
182
+ * its decimal, a boolean `true`/`false`. `undefined` renders as `""` so it can double as a stable sort
183
+ * key for edges without a guard. Deterministic and total over the `string|number|boolean` scalar set. */
184
+ function feelLiteral(value: string | number | boolean | undefined): string {
185
+ if (typeof value === "string") return feelStr(value);
186
+ if (typeof value === "number") return String(value);
187
+ if (typeof value === "boolean") return value ? "true" : "false";
188
+ return "";
189
+ }
190
+
191
+ /** The FEEL predicate a guarded edge (S7) contributes to its exclusive-split flow condition — e.g.
192
+ * `n0_result = "breaking"` — comparing the producer's published `<element>_<fact>` variable to the
193
+ * edge's `equals` literal. `when` names `<fromNode>.<fact>` (validated: a scalar fact of this edge's
194
+ * producer), so the variable is `<producerElement>_<fact>`. Returns `undefined` for a plain or default
195
+ * edge (no `when`). Deterministic. */
196
+ function guardConditionPart(
197
+ edge: ResolvedDeliveryEdge,
198
+ elementById: ReadonlyMap<string, string>,
199
+ nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
200
+ ): string | undefined {
201
+ if (edge.when === undefined || edge.default === true) return undefined;
202
+ const { fact } = resolveDeliveryFrom(edge.when, nodeFacts);
203
+ if (fact === undefined) return undefined;
204
+ const producerElement = elementById.get(edge.fromNode) ?? edge.fromNode;
205
+ return `${producerElement}_${fact} = ${feelLiteral(edge.equals)}`;
206
+ }
207
+
171
208
  /** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
172
- * (fact) label. */
209
+ * (fact / guard) label. `condition` is a FEEL boolean guard rendered as a `<bpmn:conditionExpression>`
210
+ * child (S7 guarded edge); `isDefault` marks the exclusive split's default (else) flow, whose id the
211
+ * split gateway carries as its `default` attribute. */
173
212
  interface Flow {
174
213
  id: string;
175
214
  source: string;
176
215
  target: string;
177
216
  name?: string;
217
+ condition?: string;
218
+ isDefault?: boolean;
178
219
  }
179
220
 
180
221
  /** One late-binding input a consumer node receives (S4): the producer node's business id, the
@@ -187,14 +228,18 @@ interface BoundInput {
187
228
  }
188
229
 
189
230
  /** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
190
- * or >1 upstream — the parallel fork/join gateway that fans its flow out/in. `entry` is the id
191
- * upstream flows target (the join, else the element); `exit` is the id downstream flows leave from
192
- * (the fork, else the element). */
231
+ * or >1 upstream — the fork/join gateway that fans its flow out/in. `entry` is the id upstream flows
232
+ * target (the join, else the element); `exit` is the id downstream flows leave from (the fork, else
233
+ * the element). `forkExclusive`/`joinExclusive` select an EXCLUSIVE gateway (S7): a guarded-split
234
+ * source forks on an `exclusiveGateway` (data-based branch), and a fan-in reconverging exclusive
235
+ * branches joins on an `exclusiveGateway` (first-token-proceeds) rather than a parallel AND-join. */
193
236
  interface NodeWiring {
194
237
  node: DeliveryNode;
195
238
  element: string;
196
239
  forkGateway?: string;
197
240
  joinGateway?: string;
241
+ forkExclusive: boolean;
242
+ joinExclusive: boolean;
198
243
  entry: string;
199
244
  exit: string;
200
245
  }
@@ -239,19 +284,27 @@ export async function compileDeliveryGraph(
239
284
  const edges = Array.isArray(typed.edges) ? typed.edges : [];
240
285
 
241
286
  // Resolve every edge's `from` endpoint against the SAME node/fact map the validator checked (shared
242
- // helper — no drift), then sort edges deterministically by (consumer, producer, fact).
287
+ // helper — no drift), then sort edges deterministically by (consumer, producer, fact). Guard fields
288
+ // (`when`/`equals`/`default`, S7) are carried through verbatim so the preview and the compiled
289
+ // gateway conditions derive from one resolved edge.
243
290
  const nodeFacts = deliveryNodeFacts(typed);
244
291
  const resolvedEdges: ResolvedDeliveryEdge[] = edges
245
292
  .map((edge) => {
246
293
  const { nodeId, fact } = resolveDeliveryFrom(edge.from, nodeFacts);
247
- const resolved: ResolvedDeliveryEdge = { from: edge.from, to: edge.to, fromNode: nodeId };
248
- return fact !== undefined ? { ...resolved, fromFact: fact } : resolved;
294
+ let resolved: ResolvedDeliveryEdge = { from: edge.from, to: edge.to, fromNode: nodeId };
295
+ if (fact !== undefined) resolved = { ...resolved, fromFact: fact };
296
+ if (edge.when !== undefined) resolved = { ...resolved, when: edge.when };
297
+ if (edge.equals !== undefined) resolved = { ...resolved, equals: edge.equals };
298
+ if (edge.default === true) resolved = { ...resolved, default: true };
299
+ return resolved;
249
300
  })
250
301
  .sort(
251
302
  (a, b) =>
252
303
  byCodeUnit(a.to, b.to) ||
253
304
  byCodeUnit(a.fromNode, b.fromNode) ||
254
- byCodeUnit(a.fromFact ?? "", b.fromFact ?? ""),
305
+ byCodeUnit(a.fromFact ?? "", b.fromFact ?? "") ||
306
+ byCodeUnit(a.when ?? "", b.when ?? "") ||
307
+ byCodeUnit(feelLiteral(a.equals), feelLiteral(b.equals)),
255
308
  );
256
309
 
257
310
  // Per-node producer/consumer adjacency (by node id), each sorted + de-duplicated for determinism.
@@ -268,25 +321,74 @@ export async function compileDeliveryGraph(
268
321
  for (const list of producersById.values()) list.sort(byCodeUnit);
269
322
  for (const list of consumersById.values()) list.sort(byCodeUnit);
270
323
 
324
+ // Exclusive-split topology (S7): a node with a GUARDED (`when`) out-edge is an exclusive split; a
325
+ // fan-in that re-converges a split's branches is an exclusive merge. Derived from the ONE shared
326
+ // `analyzeExclusiveTopology` the validator also uses, so gateway-type selection never drifts from the
327
+ // parity the validator enforced. A lone `default: true` edge (no guarded sibling) always fires and is
328
+ // NOT a split; and a node whose guarded + `default` edges all converge on ONE downstream target has
329
+ // no real fan-out either — mirror the validator: key off a guarded `when` fanning to >=2 DISTINCT
330
+ // downstream targets only.
331
+ const splitNodes = new Set<string>();
332
+ const guardedNodes = new Set<string>();
333
+ const branchTargetsByNode = new Map<string, Set<string>>();
334
+ for (const edge of resolvedEdges) {
335
+ const guarded = edge.when !== undefined && edge.default !== true;
336
+ if (!guarded && edge.default !== true) continue;
337
+ if (guarded) guardedNodes.add(edge.fromNode);
338
+ const targets = branchTargetsByNode.get(edge.fromNode) ?? new Set<string>();
339
+ targets.add(edge.to);
340
+ branchTargetsByNode.set(edge.fromNode, targets);
341
+ }
342
+ for (const node of guardedNodes) {
343
+ if ((branchTargetsByNode.get(node)?.size ?? 0) > 1) splitNodes.add(node);
344
+ }
345
+ const forwardAdj = new Map<string, string[]>();
346
+ for (const node of nodes) forwardAdj.set(node.id, [...(consumersById.get(node.id) ?? [])]);
347
+ const topology = analyzeExclusiveTopology(
348
+ nodes.map((n) => n.id),
349
+ forwardAdj,
350
+ splitNodes,
351
+ );
352
+
271
353
  // Assign the deterministic BPMN element id per node (`n0`, `n1`, … in sorted order) plus the
272
- // fork/join gateway ids (`gwf<i>` / `gwj<i>`) any fan-out/fan-in node needs.
354
+ // fork/join gateway ids any fan-out/fan-in node needs: a PARALLEL fork/join is `gwf<i>`/`gwj<i>`; an
355
+ // EXCLUSIVE split/merge (S7) is `gwx<i>`/`gwm<i>`. Each id space has its own positional counter so the
356
+ // scheme stays deterministic and non-colliding.
273
357
  const elementById = new Map<string, string>();
274
358
  const wirings: NodeWiring[] = [];
275
359
  const wiringById = new Map<string, NodeWiring>();
276
360
  let forkSeq = 0;
277
361
  let joinSeq = 0;
362
+ let splitSeq = 0;
363
+ let mergeSeq = 0;
278
364
  nodes.forEach((node, i) => {
279
365
  const element = `n${i}`;
280
366
  elementById.set(node.id, element);
281
367
  const consumers = consumersById.get(node.id) ?? [];
282
368
  const producers = producersById.get(node.id) ?? [];
283
- const forkGateway = consumers.length > 1 ? `gwf${forkSeq++}` : undefined;
284
- const joinGateway = producers.length > 1 ? `gwj${joinSeq++}` : undefined;
369
+ const forkExclusive = splitNodes.has(node.id);
370
+ // A fan-in is an EXCLUSIVE merge (first-token-proceeds) iff EVERY incoming branch is conditional —
371
+ // a split's own guarded/default out-edge, or a producer only conditionally reached. This is the
372
+ // SAME parity predicate the validator enforces (`edgeConditional`), so gateway-type selection never
373
+ // drifts from it. Deriving `joinExclusive` from `mergeNodes` alone over-fires: `analyzeExclusive
374
+ // Topology` marks every node reachable from >=2 branch targets as a merge, including nodes DOWNSTREAM
375
+ // of the real re-convergence — so a post-merge node that ALSO joins an independent always-firing
376
+ // producer would wrongly compile to an exclusive merge instead of the parallel AND-join both the
377
+ // validator and the semantics demand.
378
+ const joinExclusive =
379
+ producers.length > 1 &&
380
+ producers.every((p) => splitNodes.has(p) || topology.conditional.has(p));
381
+ const forkGateway =
382
+ consumers.length > 1 ? (forkExclusive ? `gwx${splitSeq++}` : `gwf${forkSeq++}`) : undefined;
383
+ const joinGateway =
384
+ producers.length > 1 ? (joinExclusive ? `gwm${mergeSeq++}` : `gwj${joinSeq++}`) : undefined;
285
385
  const wiring: NodeWiring = {
286
386
  node,
287
387
  element,
288
388
  forkGateway,
289
389
  joinGateway,
390
+ forkExclusive: forkGateway !== undefined && forkExclusive,
391
+ joinExclusive: joinGateway !== undefined && joinExclusive,
290
392
  entry: joinGateway ?? element,
291
393
  exit: forkGateway ?? element,
292
394
  };
@@ -297,7 +399,11 @@ export async function compileDeliveryGraph(
297
399
  const roots = nodes.filter((n) => (producersById.get(n.id) ?? []).length === 0);
298
400
  const leaves = nodes.filter((n) => (consumersById.get(n.id) ?? []).length === 0);
299
401
  const startForkGateway = roots.length > 1 ? "gwf_start" : undefined;
300
- const endJoinGateway = leaves.length > 1 ? "gwj_end" : undefined;
402
+ // The End sink is an exclusive merge when its leaves are mutually-exclusive branch tails (only one
403
+ // fires per run); a parallel AND-join there would deadlock on the untaken branch. The validator has
404
+ // already rejected a leaf set that MIXES conditional and always-firing tails.
405
+ const endExclusive = leaves.length > 1 && leaves.some((n) => topology.conditional.has(n.id));
406
+ const endJoinGateway = leaves.length > 1 ? (endExclusive ? "gwm_end" : "gwj_end") : undefined;
301
407
 
302
408
  // ── Build the flow list in a DETERMINISTIC order, then assign `f0…` ids positionally ────────────
303
409
  const flows: Omit<Flow, "id">[] = [];
@@ -320,30 +426,58 @@ export async function compileDeliveryGraph(
320
426
  // two fact-qualified edges between the same pair (e.g. `a.x -> b` and `a.y -> b`) would otherwise
321
427
  // emit parallel flows between endpoints with no diverging gateway — invalid BPMN that schedules
322
428
  // the consumer more than once. `resolvedEdges` is already sorted by (to, fromNode, fromFact), so
323
- // same-endpoint edges are contiguous and their fact labels accumulate in deterministic order.
324
- const collapsedEdges: { fromNode: string; to: string; facts: string[] }[] = [];
429
+ // same-endpoint edges are contiguous and their fact labels accumulate in deterministic order. For
430
+ // a guarded split (S7) the collapsed flow carries the OR of its guard conditions (or is the split
431
+ // default); the producer's exit is its exclusive gateway.
432
+ const collapsedEdges: { fromNode: string; to: string; facts: string[]; conditions: string[]; isDefault: boolean }[] =
433
+ [];
325
434
  for (const edge of resolvedEdges) {
326
435
  const last = collapsedEdges[collapsedEdges.length - 1];
436
+ const guardPart = guardConditionPart(edge, elementById, nodeFacts);
327
437
  if (last && last.fromNode === edge.fromNode && last.to === edge.to) {
328
438
  if (edge.fromFact !== undefined && !last.facts.includes(edge.fromFact)) last.facts.push(edge.fromFact);
439
+ if (edge.default === true) last.isDefault = true;
440
+ if (guardPart !== undefined && !last.conditions.includes(guardPart)) last.conditions.push(guardPart);
329
441
  } else {
330
- collapsedEdges.push({ fromNode: edge.fromNode, to: edge.to, facts: edge.fromFact !== undefined ? [edge.fromFact] : [] });
442
+ collapsedEdges.push({
443
+ fromNode: edge.fromNode,
444
+ to: edge.to,
445
+ facts: edge.fromFact !== undefined ? [edge.fromFact] : [],
446
+ conditions: guardPart !== undefined ? [guardPart] : [],
447
+ isDefault: edge.default === true,
448
+ });
331
449
  }
332
450
  }
333
451
  for (const edge of collapsedEdges) {
334
452
  const producer = mustGet(wiringById, edge.fromNode);
335
453
  const consumer = mustGet(wiringById, edge.to);
336
- const flow: Omit<Flow, "id"> = { source: producer.exit, target: consumer.entry };
337
- flows.push(edge.facts.length > 0 ? { ...flow, name: edge.facts.join(", ") } : flow);
454
+ let flow: Omit<Flow, "id"> = { source: producer.exit, target: consumer.entry };
455
+ // A guard LABEL for the diagram/preview: the fact name(s), else the rendered condition / "default".
456
+ const label =
457
+ edge.facts.length > 0
458
+ ? edge.facts.join(", ")
459
+ : edge.isDefault
460
+ ? "default"
461
+ : edge.conditions.length > 0
462
+ ? edge.conditions.join(" or ")
463
+ : undefined;
464
+ if (label !== undefined) flow = { ...flow, name: label };
465
+ if (edge.isDefault) {
466
+ flow = { ...flow, isDefault: true };
467
+ } else if (edge.conditions.length > 0) {
468
+ flow = { ...flow, condition: `=${edge.conditions.join(" or ")}` };
469
+ }
470
+ flows.push(flow);
338
471
  }
339
472
  // 5. Leaf(s) → End.
340
473
  if (leaves.length === 1) {
341
474
  flows.push({ source: mustGet(wiringById, leaves[0].id).exit, target: "End" });
342
475
  } else if (leaves.length > 1) {
476
+ const endGateway = endExclusive ? "gwm_end" : "gwj_end";
343
477
  for (const leaf of leaves) {
344
- flows.push({ source: mustGet(wiringById, leaf.id).exit, target: "gwj_end" });
478
+ flows.push({ source: mustGet(wiringById, leaf.id).exit, target: endGateway });
345
479
  }
346
- flows.push({ source: "gwj_end", target: "End" });
480
+ flows.push({ source: endGateway, target: "End" });
347
481
  }
348
482
  const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
349
483
 
@@ -532,6 +666,26 @@ function renderBpmn(
532
666
  const refs = (tag: string, ids: readonly string[]): string =>
533
667
  ids.map((id) => ` <bpmn:${tag}>${id}</bpmn:${tag}>`).join("\n");
534
668
 
669
+ // The default (else) flow id per exclusive-split gateway (S7) — the flow the gateway names in its
670
+ // `default` attribute so an unmatched runtime value takes the else-branch instead of erroring.
671
+ const defaultFlowBySource = new Map<string, string>();
672
+ for (const f of flows) if (f.isDefault) defaultFlowBySource.set(f.source, f.id);
673
+
674
+ // Render a diverging/converging gateway. `exclusive` picks `exclusiveGateway` (data-based XOR split /
675
+ // first-token merge, S7) over the parallel AND fork/join; a diverging exclusive gateway carries its
676
+ // `default` flow id when one exists.
677
+ const gateway = (id: string, exclusive: boolean, name: string): string[] => {
678
+ const tag = exclusive ? "exclusiveGateway" : "parallelGateway";
679
+ const def = defaultFlowBySource.get(id);
680
+ const defAttr = def !== undefined ? ` default="${def}"` : "";
681
+ return [
682
+ ` <bpmn:${tag} id="${id}"${defAttr} name="${escapeXml(name)}">`,
683
+ refs("incoming", incoming(id)),
684
+ refs("outgoing", outgoing(id)),
685
+ ` </bpmn:${tag}>`,
686
+ ];
687
+ };
688
+
535
689
  const lines: string[] = [];
536
690
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
537
691
  lines.push(
@@ -551,48 +705,52 @@ function renderBpmn(
551
705
  lines.push(refs("outgoing", outgoing("Start")));
552
706
  lines.push(" </bpmn:startEvent>");
553
707
 
554
- // Start fork gateway (fan-out to multiple roots).
708
+ // Start fork gateway (fan-out to multiple roots) — always a PARALLEL fork: Start unconditionally
709
+ // activates every independent root.
555
710
  if (startForkGateway) {
556
- lines.push(` <bpmn:parallelGateway id="${startForkGateway}" name="fan out to roots">`);
557
- lines.push(refs("incoming", incoming(startForkGateway)));
558
- lines.push(refs("outgoing", outgoing(startForkGateway)));
559
- lines.push(" </bpmn:parallelGateway>");
711
+ lines.push(...gateway(startForkGateway, false, "fan out to roots"));
560
712
  }
561
713
 
562
- // Node elements (sorted), each preceded by its join gateway and followed by its fork gateway.
714
+ // Node elements (sorted), each preceded by its join gateway and followed by its fork gateway. An
715
+ // exclusive split's fork (S7) is an `exclusiveGateway` with guard conditions on its out-flows; an
716
+ // exclusive-merge's join is a first-token `exclusiveGateway`.
563
717
  for (const w of wirings) {
564
718
  if (w.joinGateway) {
565
- lines.push(` <bpmn:parallelGateway id="${w.joinGateway}" name="join into ${escapeXml(w.node.id)}">`);
566
- lines.push(refs("incoming", incoming(w.joinGateway)));
567
- lines.push(refs("outgoing", outgoing(w.joinGateway)));
568
- lines.push(" </bpmn:parallelGateway>");
719
+ lines.push(...gateway(w.joinGateway, w.joinExclusive, `join into ${w.node.id}`));
569
720
  }
570
721
  lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element), boundInputsByElement.get(w.element) ?? []));
571
722
  if (w.forkGateway) {
572
- lines.push(` <bpmn:parallelGateway id="${w.forkGateway}" name="fan out of ${escapeXml(w.node.id)}">`);
573
- lines.push(refs("incoming", incoming(w.forkGateway)));
574
- lines.push(refs("outgoing", outgoing(w.forkGateway)));
575
- lines.push(" </bpmn:parallelGateway>");
723
+ lines.push(...gateway(w.forkGateway, w.forkExclusive, `fan out of ${w.node.id}`));
576
724
  }
577
725
  }
578
726
 
579
- // End join gateway (fan-in from multiple leaves) + end event.
727
+ // End join gateway (fan-in from multiple leaves) + end event. Exclusive when the leaves are
728
+ // mutually-exclusive branch tails (S7), else a parallel AND-join.
580
729
  if (endJoinGateway) {
581
- lines.push(` <bpmn:parallelGateway id="${endJoinGateway}" name="join leaves">`);
582
- lines.push(refs("incoming", incoming(endJoinGateway)));
583
- lines.push(refs("outgoing", outgoing(endJoinGateway)));
584
- lines.push(" </bpmn:parallelGateway>");
730
+ lines.push(...gateway(endJoinGateway, endJoinGateway.startsWith("gwm"), "join leaves"));
585
731
  }
586
732
  lines.push(' <bpmn:endEvent id="End" name="Graph complete">');
587
733
  lines.push(refs("incoming", incoming("End")));
588
734
  lines.push(" </bpmn:endEvent>");
589
735
 
590
- // Sequence flows.
736
+ // Sequence flows. A guarded flow (S7) carries a `<bpmn:conditionExpression>` FEEL child; the default
737
+ // flow is unconditional (the gateway names it). Condition text uses LITERAL double-quotes for FEEL
738
+ // string literals (the authored-BPMN convention, e.g. `=status = "converged"`) — text content is not
739
+ // subject to the attribute entity-decoding hazard, and the compiler grafts DI without re-serializing
740
+ // this XML, so the literal quotes survive to deploy.
591
741
  for (const f of flows) {
592
742
  const nameAttr = f.name !== undefined ? ` name="${escapeXml(f.name)}"` : "";
593
- lines.push(
594
- ` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}" />`,
595
- );
743
+ if (f.condition !== undefined) {
744
+ lines.push(
745
+ ` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}">` +
746
+ `<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${escapeXmlText(f.condition)}</bpmn:conditionExpression>` +
747
+ "</bpmn:sequenceFlow>",
748
+ );
749
+ } else {
750
+ lines.push(
751
+ ` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}" />`,
752
+ );
753
+ }
596
754
  }
597
755
 
598
756
  lines.push(" </bpmn:process>");
@@ -926,8 +1084,19 @@ function renderMermaid(
926
1084
  for (const edge of edges) {
927
1085
  const from = mustGet(elementById, edge.fromNode);
928
1086
  const to = mustGet(elementById, edge.to);
929
- if (edge.fromFact !== undefined) {
930
- lines.push(` ${from} -- "${escapeMermaid(edge.fromFact)}" --> ${to}`);
1087
+ // Label a guarded edge with its predicate (`fact == value`) and a default with `default` (S7), a
1088
+ // fact-qualified edge with the fact name, else an unlabelled arrow.
1089
+ let label: string | undefined;
1090
+ if (edge.when !== undefined && edge.default !== true) {
1091
+ const guardFact = edge.when.includes(".") ? edge.when.slice(edge.when.lastIndexOf(".") + 1) : edge.when;
1092
+ label = `${guardFact} == ${feelLiteral(edge.equals)}`;
1093
+ } else if (edge.default === true) {
1094
+ label = "default";
1095
+ } else if (edge.fromFact !== undefined) {
1096
+ label = edge.fromFact;
1097
+ }
1098
+ if (label !== undefined) {
1099
+ lines.push(` ${from} -- "${escapeMermaid(label)}" --> ${to}`);
931
1100
  } else {
932
1101
  lines.push(` ${from} --> ${to}`);
933
1102
  }
@@ -207,3 +207,151 @@ test("di coverage: every compiled flow node carries a BPMNShape and every sequen
207
207
  function escapeRe(s: string): string {
208
208
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
209
  }
210
+
211
+ // ── S7: guarded (conditional) routing DEPLOYS and ROUTES on the real engine (ADR 0005 S7) ──────────
212
+ // The compiler tests prove a guarded split emits an exclusiveGateway with FEEL conditions; only a live
213
+ // deploy proves the engine EVALUATES those conditions and takes exactly ONE branch. Here the `bump`
214
+ // agent's emitted scalar (`result`) is published to a process var and the exclusive gateway routes on
215
+ // it: the breaking outcome runs the `migrate` node, the green outcome skips straight to `release`, and
216
+ // BOTH re-converge on the exclusive merge to a COMPLETED instance (a parallel merge would deadlock the
217
+ // skipped branch). A guarded string fact needs a `default`, so green rides the else-flow.
218
+ const GUARDED_ADOPT: DeliveryGraph = {
219
+ name: "adopt runbook",
220
+ nodes: [
221
+ { id: "bump", kind: "agent", agent: { jobType: "senior:bump" }, emits: [{ name: "result", type: "string" }] },
222
+ { id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
223
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-1" } },
224
+ ],
225
+ edges: [
226
+ { from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
227
+ { from: "bump", to: "release", default: true },
228
+ { from: "migrate", to: "release" },
229
+ ],
230
+ };
231
+
232
+ async function driveGuarded(outcome: "breaking" | "green"): Promise<{ state: string; migrateRan: boolean; releaseRan: boolean }> {
233
+ const engine = await createWasmEngineClient();
234
+ try {
235
+ let migrateRan = false;
236
+ let releaseRan = false;
237
+ // The split agent publishes its scalar outcome; the exclusive gateway routes on it.
238
+ await engine.registerWorker("senior:bump", async () => ({ result: outcome }));
239
+ await engine.registerWorker("senior:migrate", async () => {
240
+ migrateRan = true;
241
+ return {};
242
+ });
243
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
244
+ releaseRan = true;
245
+ return {};
246
+ });
247
+
248
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT);
249
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
250
+ const key = run.handle.processInstanceKey;
251
+
252
+ let state = "?";
253
+ for (let round = 0; round < MAX_ROUNDS; round++) {
254
+ await engine.drain();
255
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
256
+ assert(pi, `no process instance snapshot for ${key}`);
257
+ state = pi.state ?? "?";
258
+ if (state === "COMPLETED" || state === "TERMINATED") break;
259
+ }
260
+ return { state, migrateRan, releaseRan };
261
+ } finally {
262
+ await engine.close();
263
+ }
264
+ }
265
+
266
+ test("S7 deploy+route: the breaking guard branch runs `migrate` before re-converging on the exclusive merge to COMPLETED", async () => {
267
+ const r = await driveGuarded("breaking");
268
+ assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
269
+ assert(r.migrateRan, "the breaking outcome must route through the guarded `migrate` node");
270
+ assert(r.releaseRan, "both branches must re-converge on `release`");
271
+ });
272
+
273
+ test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the else-flow straight to COMPLETED", async () => {
274
+ const r = await driveGuarded("green");
275
+ assertEquals(r.state, "COMPLETED", "the green branch must run to a COMPLETED instance");
276
+ assert(!r.migrateRan, "the green outcome must NOT route through `migrate` — it rides the default flow");
277
+ assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
278
+ });
279
+
280
+ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
281
+ // Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
282
+ // leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
283
+ // instance COMPLETE (a parallel End join would wait forever on the untaken human leaf).
284
+ const graph: DeliveryGraph = {
285
+ name: "surface check",
286
+ nodes: [
287
+ { id: "adopt", kind: "agent", agent: { jobType: "senior:adopt" }, emits: [{ name: "surface", type: "string" }] },
288
+ { id: "escalate", kind: "human", human: { prompt: "file the upstream issue" } },
289
+ { id: "done", kind: "connector", connector: { target: "npm:install", dedupeKey: "done-1" } },
290
+ ],
291
+ edges: [
292
+ { from: "adopt", to: "escalate", when: "adopt.surface", equals: "missing" },
293
+ { from: "adopt", to: "done", default: true },
294
+ ],
295
+ };
296
+
297
+ // Default path (surface present): the human leaf is skipped and the instance COMPLETES on its own.
298
+ {
299
+ const engine = await createWasmEngineClient();
300
+ try {
301
+ let doneRan = false;
302
+ await engine.registerWorker("senior:adopt", async () => ({ surface: "present" }));
303
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
304
+ doneRan = true;
305
+ return {};
306
+ });
307
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
308
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
309
+ const key = run.handle.processInstanceKey;
310
+ let state = "?";
311
+ for (let round = 0; round < MAX_ROUNDS; round++) {
312
+ await engine.drain();
313
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
314
+ state = pi?.state ?? "?";
315
+ if (state === "COMPLETED" || state === "TERMINATED") break;
316
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
317
+ assertEquals(open.length, 0, "the default path must never surface the escalate human leaf");
318
+ }
319
+ assertEquals(state, "COMPLETED", "the default (present) path completes without the human leaf");
320
+ assert(doneRan, "the default path routes to the `done` connector leaf");
321
+ } finally {
322
+ await engine.close();
323
+ }
324
+ }
325
+
326
+ // Guarded path (surface missing): the human leaf parks; `done` never runs.
327
+ {
328
+ const engine = await createWasmEngineClient();
329
+ try {
330
+ let doneRan = false;
331
+ await engine.registerWorker("senior:adopt", async () => ({ surface: "missing" }));
332
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
333
+ doneRan = true;
334
+ return {};
335
+ });
336
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
337
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
338
+ const key = run.handle.processInstanceKey;
339
+ let parked = "";
340
+ for (let round = 0; round < MAX_ROUNDS; round++) {
341
+ await engine.drain();
342
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
343
+ if (open.length > 0) {
344
+ parked = open[0].elementId ?? "";
345
+ break;
346
+ }
347
+ }
348
+ assert(
349
+ parked.startsWith("delivery-human-task__") && !parked.endsWith("__esc"),
350
+ `the missing outcome must park on the escalate human leaf, saw ${JSON.stringify(parked)}`,
351
+ );
352
+ assert(!doneRan, "the guarded (missing) path must NOT run the `done` leaf");
353
+ } finally {
354
+ await engine.close();
355
+ }
356
+ }
357
+ });
@@ -225,6 +225,31 @@ resume cannot double-fire.
225
225
  - **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
226
226
  added when a real case lands.
227
227
 
228
+ > **Amendment (issue #492): conditional (guarded) edges landed (S7).** The "behavioural edges" deferral
229
+ > above is **partially lifted**: an edge may now carry an optional guard — `when: "<node>.<fact>"` +
230
+ > `equals: <scalar>` — or be the split's single `default: true` else-branch. A node whose out-edges
231
+ > carry guards is an **exclusive** (data-based) split instead of the default parallel fan-out; its guarded branches compile to a BPMN `exclusiveGateway` (`gwx<i>`) with one FEEL
232
+ > `conditionExpression` per guarded flow (`=<producerElement>_<fact> = <literal>`) and a named default
233
+ > flow, and where those branches re-converge they merge on an **exclusive** gateway (`gwm<i>`,
234
+ > first-token-proceeds) rather than the parallel AND-join that would deadlock the untaken branch. The
235
+ > validator enforces the guard's shape and closes the deadlock/ambiguity classes: a guard must
236
+ > reference a **scalar** fact **declared by the edge's own producer** (`bad-when`), carry an `equals`
237
+ > whose type matches the fact (`guard-missing-equals` / `guard-type-mismatch`), never combine `when`
238
+ > with `default` (`guard-default-conflict`); a split must not **mix** guarded and plain out-edges
239
+ > (`mixed-fan-out`) nor declare **two** defaults (`multiple-defaults`), must be **exhaustive** — a
240
+ > `default`, unless a single boolean fact is guarded on both `true` and `false` (`non-exhaustive-split`)
241
+ > — and a plain (parallel) AND-join may not be fed by an exclusive-split branch (`exclusive-merge-parity`),
242
+ > including the implicit **End sink**: the terminal nodes may not mix a conditional (exclusive-split)
243
+ > tail with an always-firing one, which would deadlock the End join or double-fire its exclusive merge.
244
+ > The exclusive-split topology (both the validator's parity analysis and the compiler's gateway
245
+ > selection) is derived from the **guarded (`when`) edges fanning out to \>=2 distinct downstream
246
+ > targets only** — a lone `default: true` edge with no guarded sibling always fires, and a node whose
247
+ > guarded + `default` edges all converge on **one** downstream target has no real fan-out, so in
248
+ > either case its producer is **not** a split and must not mark downstream nodes/leaves conditional;
249
+ > the per-node mixing/exhaustiveness checks still apply to any `default`.
250
+ > Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
251
+ > compiles byte-for-byte as before.
252
+
228
253
  ## Open questions
229
254
 
230
255
  - **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
package/openapi.yaml CHANGED
@@ -1502,7 +1502,9 @@ components:
1502
1502
  `from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
1503
1503
  qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
1504
1504
  must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
1505
- set must be a DAG — all enforced by `validateDeliveryGraph`.
1505
+ set must be a DAG — all enforced by `validateDeliveryGraph`. An OPTIONAL `when`/`equals` guard
1506
+ (or a `default` else-branch) makes the edge CONDITIONAL, turning its producer into an
1507
+ exclusive split (ADR 0005 S7) — a node's out-edges are then ALL guarded or ALL unconditional.
1506
1508
  type: object
1507
1509
  additionalProperties: false
1508
1510
  required:
@@ -1517,6 +1519,33 @@ components:
1517
1519
  type: string
1518
1520
  minLength: 1
1519
1521
  description: The dependent node's id — proceeds once `from` is observed.
1522
+ when:
1523
+ type: string
1524
+ minLength: 1
1525
+ description: >-
1526
+ OPTIONAL guard reference `<nodeId>.<fact>` naming a SCALAR emitted fact (`string`,
1527
+ `number`, or `boolean`) of the `from`-adjacent producer (ADR 0005 S7). Its presence makes
1528
+ this a GUARDED edge and turns the producer into an exclusive-split point: the edge is taken
1529
+ only when that runtime fact `equals` the literal below. Equality-only — no arbitrary
1530
+ expressions (the trust boundary). Mutually exclusive with `default`.
1531
+ equals:
1532
+ description: >-
1533
+ The literal value `when`'s fact must equal for this guarded edge to be taken (ADR 0005 S7).
1534
+ REQUIRED iff `when` is present, and its JSON type must match the referenced fact's declared
1535
+ type (`string`/`number`/`boolean`).
1536
+ oneOf:
1537
+ - type: string
1538
+ - type: number
1539
+ - type: boolean
1540
+ default:
1541
+ type: boolean
1542
+ enum: [true]
1543
+ description: >-
1544
+ OPTIONAL — marks this edge as the ELSE branch of the exclusive split (taken when no guarded
1545
+ edge matches at runtime). A FLAG: only `true` is meaningful, so it is constrained to `true`
1546
+ (omit the field entirely for a non-default edge — `default: false` is not a valid wire
1547
+ value). At most one `default` edge per split node. Mutually exclusive with `when`/`equals`
1548
+ (ADR 0005 S7).
1520
1549
  DeliveryCompileError:
1521
1550
  description: >-
1522
1551
  One semantic-validation or compile failure, path-qualified at the offending input
@@ -1749,6 +1778,19 @@ components:
1749
1778
  fromFact:
1750
1779
  type: string
1751
1780
  description: The referenced emitted fact, when the edge `from` was qualified.
1781
+ when:
1782
+ type: string
1783
+ description: The guard reference (`<nodeId>.<fact>`) verbatim, present only on a guarded edge (ADR 0005 S7).
1784
+ equals:
1785
+ description: The literal the guard fact must equal, present only on a guarded edge (ADR 0005 S7).
1786
+ oneOf:
1787
+ - type: string
1788
+ - type: number
1789
+ - type: boolean
1790
+ default:
1791
+ type: boolean
1792
+ enum: [true]
1793
+ description: True when this is the exclusive split's default (else) branch; a FLAG, only ever `true` and omitted otherwise (ADR 0005 S7).
1752
1794
  ResolvedDeliveryGraph:
1753
1795
  description: >-
1754
1796
  The normalised graph the compiler resolved from the input (ADR 0005 slice S1) — nodes and