@particle-academy/fancy-conformance 0.11.1 → 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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.1
1
+ 0.13.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI \u2014 so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,721 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/entry-points",
4
+ "cases": [
5
+ {
6
+ "id": "0101-unset-runs-every-entry-point",
7
+ "title": "Unset: every entry point runs, exactly as before the option existed.",
8
+ "since": "0.13.0",
9
+ "tags": [
10
+ "entry-points",
11
+ "compat"
12
+ ],
13
+ "input": {
14
+ "schema": {
15
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
16
+ "version": 1,
17
+ "graph": {
18
+ "nodes": [
19
+ {
20
+ "id": "t1",
21
+ "kind": "manual_trigger",
22
+ "position": {
23
+ "x": 0,
24
+ "y": 0
25
+ }
26
+ },
27
+ {
28
+ "id": "t2",
29
+ "kind": "manual_trigger",
30
+ "position": {
31
+ "x": 0,
32
+ "y": 0
33
+ }
34
+ },
35
+ {
36
+ "id": "a",
37
+ "kind": "transform",
38
+ "position": {
39
+ "x": 1,
40
+ "y": 0
41
+ },
42
+ "config": {
43
+ "expression": "{{ $json.n }}"
44
+ }
45
+ },
46
+ {
47
+ "id": "b",
48
+ "kind": "transform",
49
+ "position": {
50
+ "x": 1,
51
+ "y": 0
52
+ },
53
+ "config": {
54
+ "expression": "{{ $json.n }}"
55
+ }
56
+ },
57
+ {
58
+ "id": "m",
59
+ "kind": "output",
60
+ "position": {
61
+ "x": 2,
62
+ "y": 0
63
+ }
64
+ }
65
+ ],
66
+ "edges": [
67
+ {
68
+ "id": "e1",
69
+ "source": "t1",
70
+ "target": "a"
71
+ },
72
+ {
73
+ "id": "e2",
74
+ "source": "t2",
75
+ "target": "b"
76
+ },
77
+ {
78
+ "id": "e3",
79
+ "source": "a",
80
+ "target": "m"
81
+ },
82
+ {
83
+ "id": "e4",
84
+ "source": "b",
85
+ "target": "m"
86
+ }
87
+ ]
88
+ }
89
+ },
90
+ "initialInputs": {
91
+ "t1": {
92
+ "n": 1
93
+ },
94
+ "t2": {
95
+ "n": 2
96
+ }
97
+ },
98
+ "entryNodes": null
99
+ },
100
+ "expected": [
101
+ "a",
102
+ "b",
103
+ "m",
104
+ "t1",
105
+ "t2"
106
+ ],
107
+ "notes": "The backward-compatibility row, and the most important one here. `null` means the caller is not using the feature, and every graph written before it existed must behave identically. A change that quietly altered this would break every multi-trigger graph in the field to fix one."
108
+ },
109
+ {
110
+ "id": "0102-naming-one-entry-skips-the-other-branch",
111
+ "title": "Naming one trigger runs its branch and skips everything reachable only from the other.",
112
+ "since": "0.13.0",
113
+ "tags": [
114
+ "entry-points"
115
+ ],
116
+ "input": {
117
+ "schema": {
118
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
119
+ "version": 1,
120
+ "graph": {
121
+ "nodes": [
122
+ {
123
+ "id": "t1",
124
+ "kind": "manual_trigger",
125
+ "position": {
126
+ "x": 0,
127
+ "y": 0
128
+ }
129
+ },
130
+ {
131
+ "id": "t2",
132
+ "kind": "manual_trigger",
133
+ "position": {
134
+ "x": 0,
135
+ "y": 0
136
+ }
137
+ },
138
+ {
139
+ "id": "a",
140
+ "kind": "transform",
141
+ "position": {
142
+ "x": 1,
143
+ "y": 0
144
+ },
145
+ "config": {
146
+ "expression": "{{ $json.n }}"
147
+ }
148
+ },
149
+ {
150
+ "id": "b",
151
+ "kind": "transform",
152
+ "position": {
153
+ "x": 1,
154
+ "y": 0
155
+ },
156
+ "config": {
157
+ "expression": "{{ $json.n }}"
158
+ }
159
+ },
160
+ {
161
+ "id": "m",
162
+ "kind": "output",
163
+ "position": {
164
+ "x": 2,
165
+ "y": 0
166
+ }
167
+ }
168
+ ],
169
+ "edges": [
170
+ {
171
+ "id": "e1",
172
+ "source": "t1",
173
+ "target": "a"
174
+ },
175
+ {
176
+ "id": "e2",
177
+ "source": "t2",
178
+ "target": "b"
179
+ },
180
+ {
181
+ "id": "e3",
182
+ "source": "a",
183
+ "target": "m"
184
+ },
185
+ {
186
+ "id": "e4",
187
+ "source": "b",
188
+ "target": "m"
189
+ }
190
+ ]
191
+ }
192
+ },
193
+ "initialInputs": {
194
+ "t1": {
195
+ "n": 1
196
+ },
197
+ "t2": {
198
+ "n": 2
199
+ }
200
+ },
201
+ "entryNodes": [
202
+ "t1"
203
+ ]
204
+ },
205
+ "expected": [
206
+ "a",
207
+ "m",
208
+ "t1"
209
+ ],
210
+ "notes": "The defect, fixed. `t2` is an entry point that was not named, so it is inactive; `b` is reachable only from `t2` and therefore has no active inbound edge. No new routing logic is involved -- the existing dead-edge rule does all of it."
211
+ },
212
+ {
213
+ "id": "0103-the-mirror-direction",
214
+ "title": "The same holds for the other trigger, so the rule is not accidentally order-dependent.",
215
+ "since": "0.13.0",
216
+ "tags": [
217
+ "entry-points"
218
+ ],
219
+ "input": {
220
+ "schema": {
221
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
222
+ "version": 1,
223
+ "graph": {
224
+ "nodes": [
225
+ {
226
+ "id": "t1",
227
+ "kind": "manual_trigger",
228
+ "position": {
229
+ "x": 0,
230
+ "y": 0
231
+ }
232
+ },
233
+ {
234
+ "id": "t2",
235
+ "kind": "manual_trigger",
236
+ "position": {
237
+ "x": 0,
238
+ "y": 0
239
+ }
240
+ },
241
+ {
242
+ "id": "a",
243
+ "kind": "transform",
244
+ "position": {
245
+ "x": 1,
246
+ "y": 0
247
+ },
248
+ "config": {
249
+ "expression": "{{ $json.n }}"
250
+ }
251
+ },
252
+ {
253
+ "id": "b",
254
+ "kind": "transform",
255
+ "position": {
256
+ "x": 1,
257
+ "y": 0
258
+ },
259
+ "config": {
260
+ "expression": "{{ $json.n }}"
261
+ }
262
+ },
263
+ {
264
+ "id": "m",
265
+ "kind": "output",
266
+ "position": {
267
+ "x": 2,
268
+ "y": 0
269
+ }
270
+ }
271
+ ],
272
+ "edges": [
273
+ {
274
+ "id": "e1",
275
+ "source": "t1",
276
+ "target": "a"
277
+ },
278
+ {
279
+ "id": "e2",
280
+ "source": "t2",
281
+ "target": "b"
282
+ },
283
+ {
284
+ "id": "e3",
285
+ "source": "a",
286
+ "target": "m"
287
+ },
288
+ {
289
+ "id": "e4",
290
+ "source": "b",
291
+ "target": "m"
292
+ }
293
+ ]
294
+ }
295
+ },
296
+ "initialInputs": {
297
+ "t1": {
298
+ "n": 1
299
+ },
300
+ "t2": {
301
+ "n": 2
302
+ }
303
+ },
304
+ "entryNodes": [
305
+ "t2"
306
+ ]
307
+ },
308
+ "expected": [
309
+ "b",
310
+ "m",
311
+ "t2"
312
+ ],
313
+ "notes": "The mirror of 0102. Asserting only one direction would pass against an implementation that hard-coded the first entry point in document order, which is exactly the kind of thing that survives a review and fails on a graph whose nodes were reordered."
314
+ },
315
+ {
316
+ "id": "0104-a-merge-fed-by-both-still-runs",
317
+ "title": "A node reachable from BOTH entry points still runs when only one fires.",
318
+ "since": "0.13.0",
319
+ "tags": [
320
+ "entry-points"
321
+ ],
322
+ "input": {
323
+ "schema": {
324
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
325
+ "version": 1,
326
+ "graph": {
327
+ "nodes": [
328
+ {
329
+ "id": "t1",
330
+ "kind": "manual_trigger",
331
+ "position": {
332
+ "x": 0,
333
+ "y": 0
334
+ }
335
+ },
336
+ {
337
+ "id": "t2",
338
+ "kind": "manual_trigger",
339
+ "position": {
340
+ "x": 0,
341
+ "y": 0
342
+ }
343
+ },
344
+ {
345
+ "id": "a",
346
+ "kind": "transform",
347
+ "position": {
348
+ "x": 1,
349
+ "y": 0
350
+ },
351
+ "config": {
352
+ "expression": "{{ $json.n }}"
353
+ }
354
+ },
355
+ {
356
+ "id": "b",
357
+ "kind": "transform",
358
+ "position": {
359
+ "x": 1,
360
+ "y": 0
361
+ },
362
+ "config": {
363
+ "expression": "{{ $json.n }}"
364
+ }
365
+ },
366
+ {
367
+ "id": "m",
368
+ "kind": "output",
369
+ "position": {
370
+ "x": 2,
371
+ "y": 0
372
+ }
373
+ }
374
+ ],
375
+ "edges": [
376
+ {
377
+ "id": "e1",
378
+ "source": "t1",
379
+ "target": "a"
380
+ },
381
+ {
382
+ "id": "e2",
383
+ "source": "t2",
384
+ "target": "b"
385
+ },
386
+ {
387
+ "id": "e3",
388
+ "source": "a",
389
+ "target": "m"
390
+ },
391
+ {
392
+ "id": "e4",
393
+ "source": "b",
394
+ "target": "m"
395
+ }
396
+ ]
397
+ }
398
+ },
399
+ "initialInputs": {
400
+ "t1": {
401
+ "n": 1
402
+ },
403
+ "t2": {
404
+ "n": 2
405
+ }
406
+ },
407
+ "entryNodes": [
408
+ "t1"
409
+ ]
410
+ },
411
+ "expected": [
412
+ "a",
413
+ "m",
414
+ "t1"
415
+ ],
416
+ "notes": "The guard against over-reaching. `m` has two inbound edges and only one is active, and the merge-after-decision contract says one active inbound is enough. An implementation that required ALL inbound edges to be active would pass 0102 and silently stop every graph that converges its branches -- which is the shape consumers are told to use as the workaround for this very bug."
417
+ },
418
+ {
419
+ "id": "0105-naming-both-is-the-same-as-unset",
420
+ "title": "Naming every entry point runs everything, matching the unset case.",
421
+ "since": "0.13.0",
422
+ "tags": [
423
+ "entry-points"
424
+ ],
425
+ "input": {
426
+ "schema": {
427
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
428
+ "version": 1,
429
+ "graph": {
430
+ "nodes": [
431
+ {
432
+ "id": "t1",
433
+ "kind": "manual_trigger",
434
+ "position": {
435
+ "x": 0,
436
+ "y": 0
437
+ }
438
+ },
439
+ {
440
+ "id": "t2",
441
+ "kind": "manual_trigger",
442
+ "position": {
443
+ "x": 0,
444
+ "y": 0
445
+ }
446
+ },
447
+ {
448
+ "id": "a",
449
+ "kind": "transform",
450
+ "position": {
451
+ "x": 1,
452
+ "y": 0
453
+ },
454
+ "config": {
455
+ "expression": "{{ $json.n }}"
456
+ }
457
+ },
458
+ {
459
+ "id": "b",
460
+ "kind": "transform",
461
+ "position": {
462
+ "x": 1,
463
+ "y": 0
464
+ },
465
+ "config": {
466
+ "expression": "{{ $json.n }}"
467
+ }
468
+ },
469
+ {
470
+ "id": "m",
471
+ "kind": "output",
472
+ "position": {
473
+ "x": 2,
474
+ "y": 0
475
+ }
476
+ }
477
+ ],
478
+ "edges": [
479
+ {
480
+ "id": "e1",
481
+ "source": "t1",
482
+ "target": "a"
483
+ },
484
+ {
485
+ "id": "e2",
486
+ "source": "t2",
487
+ "target": "b"
488
+ },
489
+ {
490
+ "id": "e3",
491
+ "source": "a",
492
+ "target": "m"
493
+ },
494
+ {
495
+ "id": "e4",
496
+ "source": "b",
497
+ "target": "m"
498
+ }
499
+ ]
500
+ }
501
+ },
502
+ "initialInputs": {
503
+ "t1": {
504
+ "n": 1
505
+ },
506
+ "t2": {
507
+ "n": 2
508
+ }
509
+ },
510
+ "entryNodes": [
511
+ "t1",
512
+ "t2"
513
+ ]
514
+ },
515
+ "expected": [
516
+ "a",
517
+ "b",
518
+ "m",
519
+ "t1",
520
+ "t2"
521
+ ],
522
+ "notes": "Pins that the option SELECTS rather than restricts-by-existing: opting in and naming everything must not be subtly different from not opting in."
523
+ },
524
+ {
525
+ "id": "0106-empty-list-runs-nothing",
526
+ "title": "An empty list says no entry point is live, and nothing runs.",
527
+ "since": "0.13.0",
528
+ "tags": [
529
+ "entry-points"
530
+ ],
531
+ "input": {
532
+ "schema": {
533
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
534
+ "version": 1,
535
+ "graph": {
536
+ "nodes": [
537
+ {
538
+ "id": "t1",
539
+ "kind": "manual_trigger",
540
+ "position": {
541
+ "x": 0,
542
+ "y": 0
543
+ }
544
+ },
545
+ {
546
+ "id": "t2",
547
+ "kind": "manual_trigger",
548
+ "position": {
549
+ "x": 0,
550
+ "y": 0
551
+ }
552
+ },
553
+ {
554
+ "id": "a",
555
+ "kind": "transform",
556
+ "position": {
557
+ "x": 1,
558
+ "y": 0
559
+ },
560
+ "config": {
561
+ "expression": "{{ $json.n }}"
562
+ }
563
+ },
564
+ {
565
+ "id": "b",
566
+ "kind": "transform",
567
+ "position": {
568
+ "x": 1,
569
+ "y": 0
570
+ },
571
+ "config": {
572
+ "expression": "{{ $json.n }}"
573
+ }
574
+ },
575
+ {
576
+ "id": "m",
577
+ "kind": "output",
578
+ "position": {
579
+ "x": 2,
580
+ "y": 0
581
+ }
582
+ }
583
+ ],
584
+ "edges": [
585
+ {
586
+ "id": "e1",
587
+ "source": "t1",
588
+ "target": "a"
589
+ },
590
+ {
591
+ "id": "e2",
592
+ "source": "t2",
593
+ "target": "b"
594
+ },
595
+ {
596
+ "id": "e3",
597
+ "source": "a",
598
+ "target": "m"
599
+ },
600
+ {
601
+ "id": "e4",
602
+ "source": "b",
603
+ "target": "m"
604
+ }
605
+ ]
606
+ }
607
+ },
608
+ "initialInputs": {
609
+ "t1": {
610
+ "n": 1
611
+ },
612
+ "t2": {
613
+ "n": 2
614
+ }
615
+ },
616
+ "entryNodes": []
617
+ },
618
+ "expected": [],
619
+ "notes": "Empty is NOT unset, and this row exists so the two can never be collapsed. `null` = 'not using the feature'; `[]` = 'no entry is live'. A runtime treating `[]` as `null` would run every branch precisely when the caller asked for none."
620
+ },
621
+ {
622
+ "id": "0107-naming-a-non-entry-node-selects-no-entry",
623
+ "title": "Naming a node that HAS inbound edges names no entry point, so nothing runs.",
624
+ "since": "0.13.0",
625
+ "tags": [
626
+ "entry-points"
627
+ ],
628
+ "input": {
629
+ "schema": {
630
+ "$schema": "https://particle.academy/schemas/workflow/v1.json",
631
+ "version": 1,
632
+ "graph": {
633
+ "nodes": [
634
+ {
635
+ "id": "t1",
636
+ "kind": "manual_trigger",
637
+ "position": {
638
+ "x": 0,
639
+ "y": 0
640
+ }
641
+ },
642
+ {
643
+ "id": "t2",
644
+ "kind": "manual_trigger",
645
+ "position": {
646
+ "x": 0,
647
+ "y": 0
648
+ }
649
+ },
650
+ {
651
+ "id": "a",
652
+ "kind": "transform",
653
+ "position": {
654
+ "x": 1,
655
+ "y": 0
656
+ },
657
+ "config": {
658
+ "expression": "{{ $json.n }}"
659
+ }
660
+ },
661
+ {
662
+ "id": "b",
663
+ "kind": "transform",
664
+ "position": {
665
+ "x": 1,
666
+ "y": 0
667
+ },
668
+ "config": {
669
+ "expression": "{{ $json.n }}"
670
+ }
671
+ },
672
+ {
673
+ "id": "m",
674
+ "kind": "output",
675
+ "position": {
676
+ "x": 2,
677
+ "y": 0
678
+ }
679
+ }
680
+ ],
681
+ "edges": [
682
+ {
683
+ "id": "e1",
684
+ "source": "t1",
685
+ "target": "a"
686
+ },
687
+ {
688
+ "id": "e2",
689
+ "source": "t2",
690
+ "target": "b"
691
+ },
692
+ {
693
+ "id": "e3",
694
+ "source": "a",
695
+ "target": "m"
696
+ },
697
+ {
698
+ "id": "e4",
699
+ "source": "b",
700
+ "target": "m"
701
+ }
702
+ ]
703
+ }
704
+ },
705
+ "initialInputs": {
706
+ "t1": {
707
+ "n": 1
708
+ },
709
+ "t2": {
710
+ "n": 2
711
+ }
712
+ },
713
+ "entryNodes": [
714
+ "a"
715
+ ]
716
+ },
717
+ "expected": [],
718
+ "notes": "Surprising, deliberate, and a consequence of the rule rather than a special case: the option gates only nodes with no incoming edges, so naming `a` leaves both real entries unnamed and therefore inactive. Pinned so no runtime 'helpfully' reinterprets it into something cleverer. A host that wants a typo to be loud must validate its own ids -- the runtime cannot distinguish a mistake from a deliberate empty selection."
719
+ }
720
+ ]
721
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/entry-points",
4
+ "title": "Which entry point fired — and therefore which nodes run",
5
+ "since": "0.13.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "runEntryPoints(schema: WorkflowSchema, initialInputs: object, entryNodes: string[]|null) -> string[]",
10
+ "summary": "Import a WorkflowSchema v1 document leniently, run it with the built-in offline executors, and return the SORTED ids of the nodes that actually EXECUTED. `entryNodes` names which entry points are live: null means unset (today's behaviour), a list names the live ones. Sorted rather than in run order, because the question this suite asks is WHICH nodes ran, not in what sequence — `flow/graph-runs` already pins ordering-sensitive behaviour.",
11
+ "reference": "php",
12
+ "referenceNote": "Filed against the PHP runtime with production measurements, but the defect is in all three: a trigger has no inbound edges, and every runtime runs a node when it has no incoming edges. The rule is written here first so no runtime has to re-derive it.",
13
+ "implementations": [
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Runtime\\RunOptions::$entryNodes" },
15
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "RunOptions.entryNodes" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "RunOptions.entry_nodes" }
17
+ ]
18
+ },
19
+ "notes": [
20
+ "THE DEFECT THIS FIXES WAS MEASURED IN PRODUCTION, not imagined. A graph may hold more than one trigger — a `manual_trigger` for hand-testing beside the event trigger that runs it for real — and every runtime executes EVERY trigger's branch on EVERY run, because a trigger node has no inbound edges and 'no inbound edges' is precisely the readiness rule. The consumer verified it is identical under both PHP queue drivers: `FlowRunner` walks a Kahn order and `Frontier::compute` restates the same rule for the per-node driver.",
21
+ "The cost is not the trigger nodes themselves — they emit a payload nobody reads. It is everything DOWNSTREAM of a trigger that did not fire. Two failures they measured: an empty payload winning a race into a shared `transform` (workaroundable by naming handles), and — with no workaround at all — a `user_input` on the manual branch executing during an EVENT-triggered run, so the run parks asking a person to paste data the event already supplied. From outside that looks like the event trigger being ignored.",
22
+ "THE RULE: `entryNodes` marks which nodes WITH NO INCOMING EDGES are live. An entry point that is not named is treated as INACTIVE — it does not run, and the existing 'at least one active inbound edge' rule then skips everything reachable only from it. Nodes that HAVE incoming edges are unaffected by the option and follow the rules they always did. That is the whole change: no new routing logic, and both schedulers already have the seam at their `incoming === []` check.",
23
+ "UNSET IS NOT THE SAME AS EMPTY, and 0101 versus 0106 pin the difference. `null` means the caller is not using the feature and gets today's behaviour exactly — this is what keeps every existing graph working. `[]` means the caller says no entry point is live, and nothing runs. Collapsing the two would either break every current consumer or make 'run nothing' unexpressible.",
24
+ "0107 is the surprising one and is deliberate. Naming a node that HAS inbound edges names no entry point, so every real entry is inactive and the graph runs nothing. That falls out of the rule rather than being a special case, and it is pinned so no runtime 'helpfully' reinterprets it. A host that wants a mistake here to be loud should validate its own ids before calling — the runtime cannot tell a typo from a deliberate empty selection."
25
+ ]
26
+ }
@@ -0,0 +1,231 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/executor-resolution",
4
+ "cases": [
5
+ {
6
+ "id": "0101-node-id-wins-over-kind",
7
+ "title": "A binding on the node's own id beats one on its kind.",
8
+ "since": "0.12.0",
9
+ "tags": ["order"],
10
+ "input": {
11
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
12
+ "bindings": [
13
+ { "key": "llm_call", "executor": "by-kind" },
14
+ { "key": "n1", "executor": "by-id" }
15
+ ],
16
+ "node": { "id": "n1", "type": "llm_call" }
17
+ },
18
+ "expected": "by-id",
19
+ "notes": "The per-node override is the whole reason id is consulted first — a graph pins ONE node to a stub without unbinding the kind for every other node using it."
20
+ },
21
+ {
22
+ "id": "0102-kind-wins-over-fallback",
23
+ "title": "A binding on the kind beats the `*` fallback.",
24
+ "since": "0.12.0",
25
+ "tags": ["order"],
26
+ "input": {
27
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
28
+ "bindings": [
29
+ { "key": "*", "executor": "catch-all" },
30
+ { "key": "llm_call", "executor": "by-kind" }
31
+ ],
32
+ "node": { "id": "n1", "type": "llm_call" }
33
+ },
34
+ "expected": "by-kind"
35
+ },
36
+ {
37
+ "id": "0103-bare-binding-namespaced-node",
38
+ "title": "A node naming the canonical id resolves a binding made under the bare alias.",
39
+ "since": "0.12.0",
40
+ "tags": ["alias"],
41
+ "input": {
42
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
43
+ "bindings": [{ "key": "llm_call", "executor": "bare" }],
44
+ "node": { "id": "n1", "type": "@particle-academy/llm_call" }
45
+ },
46
+ "expected": "bare",
47
+ "notes": "This is the direction a consumer hit in the wild: resolveKindId() hands back the NAMESPACED id, they keyed their registry by it, and the bare binding they already had stopped matching. A rename must not become a breaking change wearing a rename's costume."
48
+ },
49
+ {
50
+ "id": "0104-namespaced-binding-bare-node",
51
+ "title": "A node naming the bare alias resolves a binding made under the canonical id.",
52
+ "since": "0.12.0",
53
+ "tags": ["alias"],
54
+ "input": {
55
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
56
+ "bindings": [{ "key": "@particle-academy/llm_call", "executor": "namespaced" }],
57
+ "node": { "id": "n1", "type": "llm_call" }
58
+ },
59
+ "expected": "namespaced",
60
+ "notes": "The mirror of 0103. Asserting only one direction would pass against an implementation that resolves aliases one way, which is half a feature and the harder half to notice is missing."
61
+ },
62
+ {
63
+ "id": "0105-unknown-kind-resolves-nothing",
64
+ "title": "A node whose kind nothing is bound to, with no fallback, resolves null.",
65
+ "since": "0.12.0",
66
+ "tags": ["closed"],
67
+ "input": {
68
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
69
+ "bindings": [{ "key": "llm_call", "executor": "by-kind" }],
70
+ "node": { "id": "n1", "type": "http_request" }
71
+ },
72
+ "expected": null,
73
+ "notes": "Failing CLOSED is the correct default and is also what makes every miss in this suite silent — an unresolved node simply produces no outputs. Pinning it stops a well-meaning fall-through to an arbitrary binding from being added later."
74
+ },
75
+ {
76
+ "id": "0106-fallback-is-last",
77
+ "title": "The `*` fallback runs only after every id and kind candidate has missed.",
78
+ "since": "0.12.0",
79
+ "tags": ["closed", "order"],
80
+ "input": {
81
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
82
+ "bindings": [{ "key": "*", "executor": "catch-all" }],
83
+ "node": { "id": "n1", "type": "http_request" }
84
+ },
85
+ "expected": "catch-all"
86
+ },
87
+ {
88
+ "id": "0107-fallback-has-no-aliases",
89
+ "title": "The fallback is a sentinel: a kind literally named `*` is not expanded through the alias machinery.",
90
+ "since": "0.12.0",
91
+ "tags": ["closed"],
92
+ "input": {
93
+ "kinds": [{ "name": "*", "aliases": ["everything"] }],
94
+ "bindings": [{ "key": "everything", "executor": "aliased-star" }],
95
+ "node": { "id": "n1", "type": "http_request" }
96
+ },
97
+ "expected": null,
98
+ "notes": "Pathological on purpose. A runtime that ran the `*` sentinel through alias expansion would bind every unmatched node to whatever `everything` points at, and the graph would still complete — the failure mode this whole suite is about."
99
+ },
100
+ {
101
+ "id": "0108-unregistered-kind-binds-literally",
102
+ "title": "A kind the registry has never heard of still resolves its own literal binding.",
103
+ "since": "0.12.0",
104
+ "tags": ["alias"],
105
+ "input": {
106
+ "kinds": [],
107
+ "bindings": [{ "key": "my_custom_node", "executor": "custom" }],
108
+ "node": { "id": "n1", "type": "my_custom_node" }
109
+ },
110
+ "expected": "custom",
111
+ "notes": "Alias awareness must not become a REQUIREMENT to be registered. A host binding an ad-hoc kind it invented gets exactly that kind and no expansion, because there is no alias list to expand from and inventing one would claim knowledge nothing has."
112
+ },
113
+ {
114
+ "id": "0201-data-kind-used-when-type-is-absent",
115
+ "title": "With no type, the kind carried in data.kind resolves the executor.",
116
+ "since": "0.12.0",
117
+ "tags": ["data-kind"],
118
+ "input": {
119
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
120
+ "bindings": [{ "key": "llm_call", "executor": "by-kind" }],
121
+ "node": { "id": "n1", "type": null, "dataKind": "llm_call" }
122
+ },
123
+ "expected": "by-kind",
124
+ "skip": {
125
+ "php": "FlowNode is flattened — type IS the kind and there is no data slot, so a node without a type carries no kind at all.",
126
+ "python": "FlowNode is flattened — type IS the kind and there is no data slot, so a node without a type carries no kind at all."
127
+ },
128
+ "notes": "The legitimate use of data.kind, and the reason it is consulted at all."
129
+ },
130
+ {
131
+ "id": "0202-real-type-beats-unrelated-data-kind",
132
+ "title": "A type naming a registered kind wins over a data.kind naming a DIFFERENT registered kind.",
133
+ "since": "0.12.0",
134
+ "tags": ["data-kind", "regression"],
135
+ "input": {
136
+ "kinds": [
137
+ { "name": "@particle-academy/llm_call", "aliases": ["llm_call"] },
138
+ { "name": "@particle-academy/output", "aliases": ["output"] }
139
+ ],
140
+ "bindings": [{ "key": "@particle-academy/output", "executor": "output-exec" }],
141
+ "node": { "id": "n1", "type": "llm_call", "dataKind": "output" }
142
+ },
143
+ "expected": null,
144
+ "skip": {
145
+ "php": "FlowNode is flattened — there is no data.kind for a second opinion to live in, so this precedence question cannot arise.",
146
+ "python": "FlowNode is flattened — there is no data.kind for a second opinion to live in, so this precedence question cannot arise."
147
+ },
148
+ "notes": "fancy-flow 0.51.1 and earlier answered output-exec here: the alias step tried data.kind's ids before node.type's, so a node declaring itself an llm_call ran the OUTPUT executor. Null is correct — the node IS an llm_call and nothing is bound to that kind, so it must fail closed rather than run something else's code."
149
+ },
150
+ {
151
+ "id": "0203-real-type-beats-data-kind-even-when-both-bound",
152
+ "title": "The same precedence holds when BOTH kinds have an executor bound.",
153
+ "since": "0.12.0",
154
+ "tags": ["data-kind", "regression"],
155
+ "input": {
156
+ "kinds": [
157
+ { "name": "@particle-academy/llm_call", "aliases": ["llm_call"] },
158
+ { "name": "@particle-academy/output", "aliases": ["output"] }
159
+ ],
160
+ "bindings": [
161
+ { "key": "@particle-academy/llm_call", "executor": "llm-exec" },
162
+ { "key": "@particle-academy/output", "executor": "output-exec" }
163
+ ],
164
+ "node": { "id": "n1", "type": "llm_call", "dataKind": "output" }
165
+ },
166
+ "expected": "llm-exec",
167
+ "skip": {
168
+ "php": "FlowNode is flattened — no data.kind exists to take precedence over type.",
169
+ "python": "FlowNode is flattened — no data.kind exists to take precedence over type."
170
+ },
171
+ "notes": "The bug at its worst and the row worth reading twice. The CORRECT executor was registered, under the node's own declared kind, and 0.51.1 ran the other one anyway. 0202 could be argued away as an obscure unbound-kind edge; this cannot."
172
+ },
173
+ {
174
+ "id": "0204-category-label-in-data-kind-is-ignored",
175
+ "title": "A data.kind holding a category label rather than a kind id does not disable type.",
176
+ "since": "0.12.0",
177
+ "tags": ["data-kind", "regression"],
178
+ "input": {
179
+ "kinds": [{ "name": "@particle-academy/manual_trigger", "aliases": ["manual_trigger"] }],
180
+ "bindings": [{ "key": "@particle-academy/manual_trigger", "executor": "trigger-exec" }],
181
+ "node": { "id": "n1", "type": "manual_trigger", "dataKind": "trigger" }
182
+ },
183
+ "expected": "trigger-exec",
184
+ "skip": {
185
+ "php": "FlowNode is flattened — no data.kind.",
186
+ "python": "FlowNode is flattened — no data.kind."
187
+ },
188
+ "notes": "A category label is not a kind id — easy to write, says nothing false, and names nothing in the registry. This was the shape a consumer actually reported: the namespaced id (the one resolveKindId() hands you) was the spelling that failed, while the bare name worked. Fixed in 0.51.1; kept here so it stays fixed."
189
+ },
190
+ {
191
+ "id": "0205-renderer-type-defers-to-data-kind",
192
+ "title": "A type that names NO registered kind is a renderer type, and data.kind decides.",
193
+ "since": "0.12.0",
194
+ "tags": ["data-kind"],
195
+ "input": {
196
+ "kinds": [{ "name": "@particle-academy/llm_call", "aliases": ["llm_call"] }],
197
+ "bindings": [{ "key": "llm_call", "executor": "by-kind" }],
198
+ "node": { "id": "n1", "type": "fancyNode", "dataKind": "llm_call" }
199
+ },
200
+ "expected": "by-kind",
201
+ "skip": {
202
+ "php": "FlowNode is flattened — no data.kind.",
203
+ "python": "FlowNode is flattened — no data.kind."
204
+ },
205
+ "notes": "The row that stops the 0202-0204 fix from over-reaching. Registering ONE custom xyflow renderer and carrying the kind in data is ordinary xyflow practice; type here is a React component name, not a claim about behaviour. This is why the rule is that a type NAMING A REGISTERED KIND is authoritative, rather than the simpler and wrong rule that type always wins."
206
+ },
207
+ {
208
+ "id": "0206-node-id-still-wins-over-both",
209
+ "title": "A per-node binding still beats type and data.kind alike.",
210
+ "since": "0.12.0",
211
+ "tags": ["data-kind", "order"],
212
+ "input": {
213
+ "kinds": [
214
+ { "name": "@particle-academy/llm_call", "aliases": ["llm_call"] },
215
+ { "name": "@particle-academy/output", "aliases": ["output"] }
216
+ ],
217
+ "bindings": [
218
+ { "key": "@particle-academy/llm_call", "executor": "llm-exec" },
219
+ { "key": "n1", "executor": "pinned" }
220
+ ],
221
+ "node": { "id": "n1", "type": "llm_call", "dataKind": "output" }
222
+ },
223
+ "expected": "pinned",
224
+ "skip": {
225
+ "php": "FlowNode is flattened — no data.kind.",
226
+ "python": "FlowNode is flattened — no data.kind."
227
+ },
228
+ "notes": "Guards the fix's blast radius: reordering the kind step must not disturb the step above it."
229
+ }
230
+ ]
231
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/executor-resolution",
4
+ "title": "Which executor a node runs — id, kind, alias, fallback",
5
+ "since": "0.12.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "resolveExecutor(kinds, bindings, node) -> string | null",
10
+ "summary": "Given a kind registry (each kind with its aliases), a set of executor bindings (each an opaque LABEL bound under some key), and a node, return the label of the executor that runs — or null when nothing resolves. The answer is a label rather than a key so the table is neutral about WHEN a runtime expands aliases: PHP and Python expand at bind time and would report the key they stored under, TypeScript expands at lookup time and would report the key it matched. Both run the same executor, which is the thing a consumer can actually observe.",
11
+ "reference": "node",
12
+ "referenceNote": "The order — node id, then kind, then `*` — is fancy-flow's, and the alias step exists because kinds are namespaced (`@particle-academy/llm_call`) while hosts routinely bind the bare name. Rows 0201+ pin a TYPESCRIPT-ONLY question and are skipped elsewhere; see the notes.",
13
+ "implementations": [
14
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "pickExecutor" },
15
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\ExecutorRegistry::resolveFor" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.executors.ExecutorRegistry.resolve_for" }
17
+ ]
18
+ },
19
+ "notes": [
20
+ "THE 0200 ROWS ARE SKIPPED FOR PHP AND PYTHON ON A STRUCTURAL GROUND, NOT A CONVENIENCE ONE. TypeScript's `FlowNode` is an xyflow node — `{ id, type, data }` — so a graph can carry its kind in `data.kind` while `type` holds the RENDERER type, which is ordinary xyflow practice. PHP's and Python's `FlowNode` are FLATTENED: `type` IS the kind and there is no `data` slot for a second opinion to live in. The precedence question therefore cannot arise there, and inventing a `data.kind` field in two runtimes so they could answer rows about it would be writing code to satisfy a table — the exact inversion this package exists to prevent.",
21
+ "That asymmetry is worth stating rather than hiding, because it is WHY only one runtime had the bug these rows were written for. In fancy-flow <= 0.51.1 the alias step tried `data.kind`'s aliases BEFORE `node.type`'s, so a node with `type: \"llm_call\"` and `data.kind: \"output\"` ran the OUTPUT executor — even when an `llm_call` executor was registered (row 0203). Nothing reported it: running the wrong executor and running the right one look identical from the outside, and the graph still completes.",
22
+ "The rule the 0200 rows pin: WHEN `node.type` NAMES A REGISTERED KIND IT IS AUTHORITATIVE, and `data.kind` does not contribute at all. Otherwise `data.kind` is consulted. That keeps the xyflow pattern working (row 0205: `type: \"fancyNode\"` is a renderer, not a kind, so `data.kind` decides) while making a real kind in `type` mean what it says.",
23
+ "The 0100 rows are NOT skipped anywhere and are the substance of the cross-runtime claim: id beats kind, kind beats `*`, an alias resolves in both directions (bare binding + namespaced node, and the reverse), and an unresolvable node returns null rather than falling through to an arbitrary binding.",
24
+ "Bindings are a LIST, not a map, because insertion order is observable when two keys could both match and a JSON object's key order is not something every language's decoder preserves.",
25
+ "`*` is a sentinel, not a kind: it has no aliases and is only consulted after every id-and-kind candidate has missed (row 0106). A runtime that expanded `*` through the alias machinery would bind every kind to the fallback."
26
+ ]
27
+ }