@openwop/openwop-conformance 2.46.5 → 2.46.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [2.46.7] — 2026-10-14 — one id per leg; RFC 0243 G2 closes
2
+
3
+ - **RFC 0243 G2 closes.** No assertion changes; the packed `spec/v1/gaps.json` changes. openwop-app's certified 2.46.6 cut (build `44960bca5`) records all three G2 ids `executed-pass`.
4
+ - **Registry: a requirement id passed through a helper is registered.** A scenario writing `assert(ID_X, judge(o))`, where the helper calls `req(id, …)`, registered `explicitId: null`. `src/setup.ts` labels a soft-skipped row from `explicitId`, so on a host where the leg was `inapplicable` the row read `openwop.it.<file>.<slug>`, and on a host where it ran, the requirement id. One leg, two ids, depending on the host. Found by openwop-app-8f on `v2-agents-fan-out-waves`.
5
+ - The generator now takes the first `openwop.requirement.*` const passed as a call's first argument when the body has no `req()`. 20 records in 7 files gain their id, and no existing id changes: `v2-agents-fan-out-waves`, `v2-dispatch-loop`, `v2-dispatch-input-mapping`, `v2-subworkflow-input-mapping`, `v2-subworkflow-linkage`, `v2-host-event-delivery`, `v2-safefetch-ssrf-refused`.
6
+ - **What a host's next bundle shows:** those legs' non-executed rows are renamed from `openwop.it.*` to their requirement ids. Executed rows are unchanged. A bundle cut on an older suite lacks rows for the three 2.46.6 ids under `check-cut-gates` Witness, which is the usual currency effect.
7
+ - A self-test pins the registered ids for two of the files.
8
+
9
+ ## [2.46.6] — 2026-10-13 — RFC 0243 G2: fan-out waves and per-item input
10
+
11
+ - **New `v2-agents-fan-out-waves`** (three ids, legs in `lib/fan-out-witness.ts`). The v1 twins drove a seam v2 does not carry; these run fixtures whose supervisor emits its `mockDispatchPlan`.
12
+ - `0243.max-fan-out-waves`, on `conformance-dispatch-fanout-waves`: three `conformance-delay` children under `maxConcurrency: 2` all complete, `dispatch.fanned-out` and `dispatch.joined` count three, and no more than `min(2, maxFanOut)` child windows overlap by more than 100 ms. Gated on `agents.fanOut` admitting `wait-all` with `collect`.
13
+ - `0243.per-item-input-gated`, on `conformance-dispatch-per-item`: at `agents.fanOut.perItemInput: true` each child receives its own `nextWorkerInputs` item; on any other dispatch host the dispatch node fails `validation_error` and no child is dispatched (a `400 validation_error` at `createRun` also satisfies it).
14
+ - `agents.dispatch-per-item-length-mismatch`, on `conformance-dispatch-per-item-mismatch`: one item for two children fails the dispatch node `validation_error`, no child, on every dispatch host.
15
+ - **What a host's next bundle shows:** the fixtures are opt-in through discovery `fixtures`, so a host that does not list them records all three `inapplicable`. A dispatch host that lists the per-item fixtures and drops `nextWorkerInputs` records `executed-fail`.
16
+ - `ObservedRun` gains an optional `createBody`; the run-graph double can refuse a create and add snapshot members.
17
+
1
18
  ## [2.46.5] — 2026-10-13 — RFC 0243 Accepted; a fan-out prediction corrected
2
19
 
3
20
  - **No assertion changes.** This cycle opens because RFC 0243's gap G1 closes, which changes the packed `spec/v1/gaps.json`.
package/README.md CHANGED
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
137
137
 
138
138
  ## What's Covered
139
139
 
140
- The current suite has 656 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 216 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
140
+ The current suite has 657 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 217 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
141
141
 
142
142
  The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
143
143
 
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
166
166
  - **Host** checks drive a live host over HTTP.
167
167
  - **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
168
168
 
169
- Current source tree: 656 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
169
+ Current source tree: 657 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
170
170
 
171
171
  ## How the 2.x suite is built
172
172
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.46.5",
4
- "stampSha256": "0f3441572726c05b4ce03f9892f3b90ef77a8e37fa913c5f069fef3553cedf94"
3
+ "version": "2.46.7",
4
+ "stampSha256": "aa3b7b3012274a6d025e75053ff2697d94062a06f4dd2fee48e2ef01964f8581"
5
5
  }
@@ -0,0 +1,84 @@
1
+ {
2
+ "id": "conformance-dispatch-fanout-waves",
3
+ "name": "Conformance: Dispatch Fan-Out Waves",
4
+ "version": "1.0",
5
+ "description": "A parallel fan-out of three conformance-delay children with maxConcurrency 2: at most two children run at once, and none is dropped (RFC 0243 \u00a7A.3).",
6
+ "nodes": [
7
+ {
8
+ "id": "supervisor",
9
+ "typeId": "core.orchestrator.supervisor",
10
+ "name": "Supervisor",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "mockDispatchPlan": [
17
+ {
18
+ "kind": "next-worker",
19
+ "nextWorkerIds": [
20
+ "conformance-delay",
21
+ "conformance-delay",
22
+ "conformance-delay"
23
+ ]
24
+ },
25
+ {
26
+ "kind": "terminate",
27
+ "reason": "goal-reached"
28
+ }
29
+ ]
30
+ },
31
+ "inputs": {}
32
+ },
33
+ {
34
+ "id": "dispatch",
35
+ "typeId": "core.dispatch",
36
+ "name": "Dispatch",
37
+ "position": {
38
+ "x": 200,
39
+ "y": 0
40
+ },
41
+ "config": {
42
+ "workerDispatchModel": "child-run",
43
+ "fanOutPolicy": "parallel",
44
+ "maxConcurrency": 2,
45
+ "joinPolicy": {
46
+ "mode": "wait-all",
47
+ "onChildFailure": "collect"
48
+ }
49
+ },
50
+ "inputs": {}
51
+ }
52
+ ],
53
+ "edges": [
54
+ {
55
+ "id": "e1",
56
+ "sourceNodeId": "supervisor",
57
+ "targetNodeId": "dispatch"
58
+ },
59
+ {
60
+ "id": "e2",
61
+ "sourceNodeId": "dispatch",
62
+ "targetNodeId": "supervisor"
63
+ }
64
+ ],
65
+ "triggers": [
66
+ {
67
+ "id": "manual",
68
+ "type": "manual",
69
+ "enabled": true
70
+ }
71
+ ],
72
+ "variables": [],
73
+ "metadata": {
74
+ "tags": [
75
+ "conformance",
76
+ "dispatch",
77
+ "fan-out"
78
+ ]
79
+ },
80
+ "settings": {
81
+ "timeout": 60000,
82
+ "maxRetries": 0
83
+ }
84
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "id": "conformance-dispatch-per-item-mismatch",
3
+ "name": "Conformance: Dispatch Per-Item Length Mismatch",
4
+ "version": "1.0",
5
+ "description": "A next-worker decision whose nextWorkerInputs is shorter than nextWorkerIds. Every host fails the dispatch node with validation_error and dispatches no child (RFC 0126, orchestrator-decision.schema.json nextWorkerInputs).",
6
+ "nodes": [
7
+ {
8
+ "id": "supervisor",
9
+ "typeId": "core.orchestrator.supervisor",
10
+ "name": "Supervisor",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "mockDispatchPlan": [
17
+ {
18
+ "kind": "next-worker",
19
+ "nextWorkerIds": [
20
+ "conformance-dispatch-input-mapping-child",
21
+ "conformance-dispatch-input-mapping-child"
22
+ ],
23
+ "nextWorkerInputs": [
24
+ {
25
+ "childGreeting": "item-a"
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "kind": "terminate",
31
+ "reason": "goal-reached"
32
+ }
33
+ ]
34
+ },
35
+ "inputs": {}
36
+ },
37
+ {
38
+ "id": "dispatch",
39
+ "typeId": "core.dispatch",
40
+ "name": "Dispatch",
41
+ "position": {
42
+ "x": 200,
43
+ "y": 0
44
+ },
45
+ "config": {
46
+ "workerDispatchModel": "child-run",
47
+ "fanOutPolicy": "sequential"
48
+ },
49
+ "inputs": {}
50
+ }
51
+ ],
52
+ "edges": [
53
+ {
54
+ "id": "e1",
55
+ "sourceNodeId": "supervisor",
56
+ "targetNodeId": "dispatch"
57
+ },
58
+ {
59
+ "id": "e2",
60
+ "sourceNodeId": "dispatch",
61
+ "targetNodeId": "supervisor"
62
+ }
63
+ ],
64
+ "triggers": [
65
+ {
66
+ "id": "manual",
67
+ "type": "manual",
68
+ "enabled": true
69
+ }
70
+ ],
71
+ "variables": [],
72
+ "metadata": {
73
+ "tags": [
74
+ "conformance",
75
+ "dispatch",
76
+ "per-item-input"
77
+ ]
78
+ },
79
+ "settings": {
80
+ "timeout": 60000,
81
+ "maxRetries": 0
82
+ }
83
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "id": "conformance-dispatch-per-item",
3
+ "name": "Conformance: Dispatch Per-Item Input",
4
+ "version": "1.0",
5
+ "description": "One next-worker decision dispatching the same child twice with distinct nextWorkerInputs. A host advertising agents.fanOut.perItemInput true projects each item into its child; any other host fails the dispatch node with validation_error and dispatches no child (RFC 0243 \u00a7A.4).",
6
+ "nodes": [
7
+ {
8
+ "id": "supervisor",
9
+ "typeId": "core.orchestrator.supervisor",
10
+ "name": "Supervisor",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "mockDispatchPlan": [
17
+ {
18
+ "kind": "next-worker",
19
+ "nextWorkerIds": [
20
+ "conformance-dispatch-input-mapping-child",
21
+ "conformance-dispatch-input-mapping-child"
22
+ ],
23
+ "nextWorkerInputs": [
24
+ {
25
+ "childGreeting": "item-a"
26
+ },
27
+ {
28
+ "childGreeting": "item-b"
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "kind": "terminate",
34
+ "reason": "goal-reached"
35
+ }
36
+ ]
37
+ },
38
+ "inputs": {}
39
+ },
40
+ {
41
+ "id": "dispatch",
42
+ "typeId": "core.dispatch",
43
+ "name": "Dispatch",
44
+ "position": {
45
+ "x": 200,
46
+ "y": 0
47
+ },
48
+ "config": {
49
+ "workerDispatchModel": "child-run",
50
+ "fanOutPolicy": "sequential"
51
+ },
52
+ "inputs": {}
53
+ }
54
+ ],
55
+ "edges": [
56
+ {
57
+ "id": "e1",
58
+ "sourceNodeId": "supervisor",
59
+ "targetNodeId": "dispatch"
60
+ },
61
+ {
62
+ "id": "e2",
63
+ "sourceNodeId": "dispatch",
64
+ "targetNodeId": "supervisor"
65
+ }
66
+ ],
67
+ "triggers": [
68
+ {
69
+ "id": "manual",
70
+ "type": "manual",
71
+ "enabled": true
72
+ }
73
+ ],
74
+ "variables": [],
75
+ "metadata": {
76
+ "tags": [
77
+ "conformance",
78
+ "dispatch",
79
+ "per-item-input"
80
+ ]
81
+ },
82
+ "settings": {
83
+ "timeout": 60000,
84
+ "maxRetries": 0
85
+ }
86
+ }
package/fixtures.md CHANGED
@@ -69,6 +69,9 @@ All fixtures MUST advertise:
69
69
  | Prompt All Four Kinds | `conformance-prompt-all-four-kinds` | RFC 0027 §A four-kind dispatch coverage with a MULTI-ENTRY `fewShotPromptRefs[]` array. Single `mock-ai` node with one ref per singular-kind slot (`systemPromptRef`, `userPromptRef`, `schemaHintPromptRef`) + two distinct templateIds in `fewShotPromptRefs[]`; host MUST emit 5 `agent.promptResolved` events (one per slot) AND 5 `prompt.composed` events. Multi-entry few-shot is the regression pin for `fewShotPromptRefs[slotIndex]` per-index resolution — a host that hard-codes `[0]` would emit the same template twice in the few-shot events and fail the per-templateId assertion. Capability-gated on `capabilities.prompts.supported`. | `completed` | ≤ 10s |
70
70
  | Dispatch Loop | `conformance-dispatch-loop` | Verifies `core.dispatch` loop mechanism | `completed` | ≤ 30s |
71
71
  | Dispatch Fan-Out Race | `conformance-dispatch-fanout-race` | RFC 0243 §A.2 — a `core.dispatch` node pinning `fanOutPolicy: 'parallel'` with a `race` join. A host that does not admit it refuses it at load, or at `createRun` with `400 validation_error` and `details.requiredCapability` (`v2-agents-fan-out`) | refused; no run | ≤ 5s |
72
+ | Dispatch Fan-Out Waves | `conformance-dispatch-fanout-waves` | RFC 0243 §A.3 — a parallel `core.dispatch` (`maxConcurrency: 2`, `wait-all`/`collect`) whose supervisor's `mockDispatchPlan` names three `conformance-delay` children. At most `min(2, maxFanOut)` run at once and none is dropped. Gated on `agents.fanOut` admitting `wait-all` with `collect`. | `completed` | ≤ 30s |
73
+ | Dispatch Per-Item Input | `conformance-dispatch-per-item` | RFC 0243 §A.4 — a sequential `core.dispatch` whose decision dispatches `conformance-dispatch-input-mapping-child` twice with `nextWorkerInputs` `item-a`, `item-b`. At `agents.fanOut.perItemInput: true` each child receives its item; otherwise the dispatch node fails `validation_error` and no child is dispatched. | `completed`, or `failed` without `perItemInput` | ≤ 30s |
74
+ | Dispatch Per-Item Length Mismatch | `conformance-dispatch-per-item-mismatch` | `orchestrator-decision.schema.json` `nextWorkerInputs` — one item for two children. Every dispatch host fails the dispatch node `validation_error` and dispatches no child. | `failed` | ≤ 30s |
72
75
  | Interrupt — Quorum | `conformance-interrupt-quorum` | Verifies `openwop-interrupt-quorum` profile (multi-approver, majority rejection) | `completed` after 3 accepts, `failed` after quorum reject (major 2: `approval_rejected`) | unbounded (suspends) |
73
76
  | Interrupt — External Event | `conformance-interrupt-external-event` | Verifies `openwop-interrupt-external-event` profile (correlation-matched callback) | `completed` after matching POST, `failed` on timeout | ≤ 60s (timeoutMs configured) |
74
77
  | Interrupt — Auth Required | `conformance-interrupt-auth-required` | Verifies `openwop-interrupt-auth-required` profile (bearer-token resume only) | `completed` after bearer resolve | unbounded (suspends) |
@@ -396,6 +399,47 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
396
399
  ---
397
400
 
398
401
 
402
+ ### `conformance-dispatch-fanout-waves`
403
+
404
+ - **Purpose**: observe the RFC 0243 §A.3 breadth rule: the effective concurrency is `min(config.maxConcurrency, maxFanOut)`, children beyond it run in waves, and none is dropped (`spec/v2/facets/agents.schema.json` §fanOut).
405
+ - **Topology**: `core.orchestrator.supervisor` ⇄ `core.dispatch`. The supervisor's `config.mockDispatchPlan` is `next-worker` with `nextWorkerIds` three times `conformance-delay`, then `terminate`. The dispatch node is `{ workerDispatchModel: "child-run", fanOutPolicy: "parallel", maxConcurrency: 2, joinPolicy: { mode: "wait-all", onChildFailure: "collect" } }`.
406
+ - **Inputs**: none. Each child sleeps its default `delayMs` (1000).
407
+ - **Conformance test driver**:
408
+ 1. Skip unless `agents.dispatch` is `true`, `agents.fanOut` lists `wait-all` and `collect`, and discovery `fixtures` lists this fixture and `conformance-delay`.
409
+ 2. POST `/runs` with `{workflowId: "conformance-dispatch-fanout-waves"}`; poll until terminal.
410
+ 3. **Assert** the run completed; one `dispatch.fanned-out` with `childCount` 3 and any `maxConcurrency` at most `min(2, maxFanOut)`; one `dispatch.joined` with `completedCount` 3, `failedCount` 0 and a `mergeOrder` of 3.
411
+ 4. Read each child (`node.dispatched`, else `mergeOrder`). **Assert** three children, each `completed`, and that no more than `min(2, maxFanOut)` of their `startedAt`..`completedAt` windows overlap by more than 100 ms.
412
+ - **Terminal status**: `completed`.
413
+ - **Server prerequisites**: the supervisor emits the `mockDispatchPlan` verbatim, as for `conformance-dispatch-cross-worker-handoff`.
414
+
415
+ ---
416
+
417
+ ### `conformance-dispatch-per-item`
418
+
419
+ - **Purpose**: observe the RFC 0243 §A.4 gate on `nextWorkerInputs`: honored at `agents.fanOut.perItemInput: true`, failed closed otherwise.
420
+ - **Topology**: `core.orchestrator.supervisor` ⇄ `core.dispatch` (`fanOutPolicy: "sequential"`). The `mockDispatchPlan` is `next-worker` with `nextWorkerIds` twice `conformance-dispatch-input-mapping-child` and `nextWorkerInputs` `[{childGreeting: "item-a"}, {childGreeting: "item-b"}]`, then `terminate`.
421
+ - **Inputs**: none.
422
+ - **Conformance test driver**:
423
+ 1. Skip unless `agents.dispatch` is `true` and discovery `fixtures` lists this fixture and `conformance-dispatch-input-mapping-child`.
424
+ 2. POST `/runs`; poll until terminal; read each child found by `node.dispatched`.
425
+ 3. At `perItemInput: true`, **assert** the run completed, two children were dispatched, and their `inputs.childGreeting` are `item-a` and `item-b`.
426
+ 4. Otherwise, **assert** the dispatch node's `node.failed` carries `error.code: "validation_error"`, no child was dispatched, and the run ended `failed`. A `400 validation_error` at `createRun` also satisfies this step.
427
+ - **Terminal status**: `completed` at `perItemInput: true`; otherwise `failed`.
428
+ - **Server prerequisites**: as `conformance-dispatch-fanout-waves`.
429
+
430
+ ---
431
+
432
+ ### `conformance-dispatch-per-item-mismatch`
433
+
434
+ - **Purpose**: observe the length rule on `nextWorkerInputs` (`orchestrator-decision.schema.json`): a length that differs from `nextWorkerIds` fails the dispatch node with `validation_error` and dispatches no child.
435
+ - **Topology**: as `conformance-dispatch-per-item`, with one item for two children.
436
+ - **Inputs**: none.
437
+ - **Conformance test driver**: steps 1, 2 and 4 of `conformance-dispatch-per-item`, on every dispatch host.
438
+ - **Terminal status**: `failed`.
439
+ - **Server prerequisites**: as `conformance-dispatch-fanout-waves`.
440
+
441
+ ---
442
+
399
443
  ### `conformance-dispatch-fanout-race`
400
444
 
401
445
  - **Purpose**: observe the RFC 0243 §A.2 refusal of a parallel fan-out the host does not admit (`spec/v2/facets/agents.schema.json` §fanOut).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.46.5",
3
+ "version": "2.46.7",
4
4
  "description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.46.5"
59
+ "@openwop/spec-artifacts": "2.46.7"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 744,
6
- "tests": 2797,
7
- "withStableId": 2797,
5
+ "files": 745,
6
+ "tests": 2800,
7
+ "withStableId": 2800,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2653
9
+ "explicitIds": 2673
10
10
  },
11
11
  "records": [
12
12
  {
@@ -28106,6 +28106,30 @@
28106
28106
  }
28107
28107
  ]
28108
28108
  },
28109
+ {
28110
+ "id": "openwop.it.v2-agents-fan-out-waves.children-beyond-the-effective-ceiling-are-dispatched-in-waves-and-none-is-droppe",
28111
+ "file": "v2-agents-fan-out-waves.test.ts",
28112
+ "line": 64,
28113
+ "title": "children beyond the effective ceiling are dispatched in waves, and none is dropped",
28114
+ "explicitId": "openwop.requirement.0243.max-fan-out-waves",
28115
+ "citations": []
28116
+ },
28117
+ {
28118
+ "id": "openwop.it.v2-agents-fan-out-waves.nextworkerinputs-reaches-each-child-at-periteminput-true-and-fails-closed-withou",
28119
+ "file": "v2-agents-fan-out-waves.test.ts",
28120
+ "line": 72,
28121
+ "title": "nextWorkerInputs reaches each child at perItemInput: true, and fails closed without it",
28122
+ "explicitId": "openwop.requirement.0243.per-item-input-gated",
28123
+ "citations": []
28124
+ },
28125
+ {
28126
+ "id": "openwop.it.v2-agents-fan-out-waves.a-nextworkerinputs-length-that-differs-from-nextworkerids-fails-the-dispatch-nod",
28127
+ "file": "v2-agents-fan-out-waves.test.ts",
28128
+ "line": 80,
28129
+ "title": "a nextWorkerInputs length that differs from nextWorkerIds fails the dispatch node and dispatches no child",
28130
+ "explicitId": "openwop.requirement.agents.dispatch-per-item-length-mismatch",
28131
+ "citations": []
28132
+ },
28109
28133
  {
28110
28134
  "id": "openwop.it.v2-agents-fan-out.a-host-advertising-agents-fanout-advertises-agents-dispatch-true",
28111
28135
  "file": "v2-agents-fan-out.test.ts",
@@ -30181,7 +30205,7 @@
30181
30205
  "file": "v2-dispatch-input-mapping.test.ts",
30182
30206
  "line": 59,
30183
30207
  "title": "inputMapping projects the parent variable into the dispatched child inputs",
30184
- "explicitId": null,
30208
+ "explicitId": "openwop.requirement.agents.dispatch-input-mapping-projection",
30185
30209
  "citations": []
30186
30210
  },
30187
30211
  {
@@ -30189,7 +30213,7 @@
30189
30213
  "file": "v2-dispatch-input-mapping.test.ts",
30190
30214
  "line": 65,
30191
30215
  "title": "an unset parent variable projects to undefined: the child input is absent, never null",
30192
- "explicitId": null,
30216
+ "explicitId": "openwop.requirement.agents.dispatch-input-mapping-unset-undefined",
30193
30217
  "citations": []
30194
30218
  },
30195
30219
  {
@@ -30197,7 +30221,7 @@
30197
30221
  "file": "v2-dispatch-loop.test.ts",
30198
30222
  "line": 63,
30199
30223
  "title": "each turn records its decision and terminate completes the run",
30200
- "explicitId": null,
30224
+ "explicitId": "openwop.requirement.multi-agent.supervisor-loop-terminates",
30201
30225
  "citations": []
30202
30226
  },
30203
30227
  {
@@ -30205,7 +30229,7 @@
30205
30229
  "file": "v2-dispatch-loop.test.ts",
30206
30230
  "line": 69,
30207
30231
  "title": "every recorded decision is well-formed and the deciding agent never changes",
30208
- "explicitId": null,
30232
+ "explicitId": "openwop.requirement.multi-agent.decision-recorded-well-formed",
30209
30233
  "citations": []
30210
30234
  },
30211
30235
  {
@@ -30213,7 +30237,7 @@
30213
30237
  "file": "v2-dispatch-loop.test.ts",
30214
30238
  "line": 75,
30215
30239
  "title": "at executionModel.version >= 5, iteration is 1-based and increments by exactly 1 per turn",
30216
- "explicitId": null,
30240
+ "explicitId": "openwop.requirement.multi-agent.decision-iteration-monotonic",
30217
30241
  "citations": []
30218
30242
  },
30219
30243
  {
@@ -31433,7 +31457,7 @@
31433
31457
  "file": "v2-host-event-delivery.test.ts",
31434
31458
  "line": 174,
31435
31459
  "title": "an ephemeral host event has no id: and is not redelivered after Last-Event-ID",
31436
- "explicitId": null,
31460
+ "explicitId": "openwop.requirement.0236.ephemeral.no-resume",
31437
31461
  "citations": []
31438
31462
  },
31439
31463
  {
@@ -31473,7 +31497,7 @@
31473
31497
  "file": "v2-host-event-delivery.test.ts",
31474
31498
  "line": 243,
31475
31499
  "title": "the same subscription never receives the ephemeral host event",
31476
- "explicitId": null,
31500
+ "explicitId": "openwop.requirement.0236.ephemeral.no-fan-out",
31477
31501
  "citations": []
31478
31502
  },
31479
31503
  {
@@ -31481,7 +31505,7 @@
31481
31505
  "file": "v2-host-event-delivery.test.ts",
31482
31506
  "line": 256,
31483
31507
  "title": "emitTestHostEvent is served exactly for the listed host-test classes",
31484
- "explicitId": null,
31508
+ "explicitId": "openwop.requirement.0241.trigger.bound-by-listing",
31485
31509
  "citations": []
31486
31510
  },
31487
31511
  {
@@ -31489,7 +31513,7 @@
31489
31513
  "file": "v2-host-event-delivery.test.ts",
31490
31514
  "line": 270,
31491
31515
  "title": "a second tenant receives nothing for the first tenant’s host event",
31492
- "explicitId": null,
31516
+ "explicitId": "openwop.requirement.0236.host-event.tenant-isolation",
31493
31517
  "citations": []
31494
31518
  },
31495
31519
  {
@@ -36561,7 +36585,7 @@
36561
36585
  "file": "v2-safefetch-ssrf-refused.test.ts",
36562
36586
  "line": 166,
36563
36587
  "title": "an RFC 1918 target is refused egress_denied, ssrf-blocked",
36564
- "explicitId": null,
36588
+ "explicitId": "openwop.requirement.httpClient.ssrf-rfc1918-refused",
36565
36589
  "citations": []
36566
36590
  },
36567
36591
  {
@@ -36569,7 +36593,7 @@
36569
36593
  "file": "v2-safefetch-ssrf-refused.test.ts",
36570
36594
  "line": 172,
36571
36595
  "title": "a link-local or cloud-metadata target is refused egress_denied, ssrf-blocked",
36572
- "explicitId": null,
36596
+ "explicitId": "openwop.requirement.httpClient.ssrf-link-local-metadata-refused",
36573
36597
  "citations": []
36574
36598
  },
36575
36599
  {
@@ -37351,7 +37375,7 @@
37351
37375
  "file": "v2-subworkflow-input-mapping.test.ts",
37352
37376
  "line": 57,
37353
37377
  "title": "inputMapping seeds the child variable bag at creation",
37354
- "explicitId": null,
37378
+ "explicitId": "openwop.requirement.sub-workflow.input-mapping-seeds-child",
37355
37379
  "citations": []
37356
37380
  },
37357
37381
  {
@@ -37359,7 +37383,7 @@
37359
37383
  "file": "v2-subworkflow-input-mapping.test.ts",
37360
37384
  "line": 63,
37361
37385
  "title": "the mapped parent value lands after and over the child defaultValue",
37362
- "explicitId": null,
37386
+ "explicitId": "openwop.requirement.sub-workflow.input-mapping-overrides-default",
37363
37387
  "citations": []
37364
37388
  },
37365
37389
  {
@@ -37367,7 +37391,7 @@
37367
37391
  "file": "v2-subworkflow-input-mapping.test.ts",
37368
37392
  "line": 69,
37369
37393
  "title": "an unset parent variable arrives undefined, never an error or null",
37370
- "explicitId": null,
37394
+ "explicitId": "openwop.requirement.sub-workflow.input-mapping-unset-undefined",
37371
37395
  "citations": []
37372
37396
  },
37373
37397
  {
@@ -37375,7 +37399,7 @@
37375
37399
  "file": "v2-subworkflow-linkage.test.ts",
37376
37400
  "line": 59,
37377
37401
  "title": "node.completed for the core.subWorkflow node carries outputs.childRunId and outputs.childStatus",
37378
- "explicitId": null,
37402
+ "explicitId": "openwop.requirement.sub-workflow.node-completed-child-outputs",
37379
37403
  "citations": []
37380
37404
  },
37381
37405
  {
@@ -37383,7 +37407,7 @@
37383
37407
  "file": "v2-subworkflow-linkage.test.ts",
37384
37408
  "line": 65,
37385
37409
  "title": "outputMapping copies the child variable into the parent after the child completes",
37386
- "explicitId": null,
37410
+ "explicitId": "openwop.requirement.sub-workflow.output-mapping-copied",
37387
37411
  "citations": []
37388
37412
  },
37389
37413
  {
@@ -37391,7 +37415,7 @@
37391
37415
  "file": "v2-subworkflow-linkage.test.ts",
37392
37416
  "line": 71,
37393
37417
  "title": "getRunAncestry names the parent run with cause core.subWorkflow",
37394
- "explicitId": null,
37418
+ "explicitId": "openwop.requirement.sub-workflow.ancestry-parent-link",
37395
37419
  "citations": []
37396
37420
  },
37397
37421
  {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 656,
4
+ "files": 657,
5
5
  "v1": 455,
6
- "v2": 216
6
+ "v2": 217
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1302,6 +1302,9 @@
1302
1302
  "v2-agent-runtime-advertisement.test.ts": [
1303
1303
  2
1304
1304
  ],
1305
+ "v2-agents-fan-out-waves.test.ts": [
1306
+ 2
1307
+ ],
1305
1308
  "v2-agents-fan-out.test.ts": [
1306
1309
  2
1307
1310
  ],
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.46.5",
5
- "corpusTag": "v2.46.5",
4
+ "version": "2.46.7",
5
+ "corpusTag": "v2.46.7",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "d24c33f8922c3013801ec951c81ea66f77060769b8cd9daad3b8d1f75b7c5eb7",
13
- "api/v2/asyncapi.yaml": "13dcfe2684e3f12f47222c10b582731f0a72ce9d70758c208bd37e454041fc66",
14
- "api/v2/openapi.yaml": "b7b485f7bab904669f25b8307b4b17fe88e971eb156d258f3cae73492ce5e6ff",
12
+ "api/seams-v2.yaml": "1f51bb8566237c46f9df4d262f218667143e72664a56403838787360f0e543fb",
13
+ "api/v2/asyncapi.yaml": "a4f623300eee3948f8debdf07cdf82c26ae7e6ceb9a6a15bd72a31adcd103690",
14
+ "api/v2/openapi.yaml": "f1229644f8d1fa6d9e9b7e3ed83595e45daaf20ba4c8cad6b52fbdfa335b3575",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -208,14 +208,14 @@
208
208
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
209
209
  "spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
210
210
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
211
- "spec/v1/core-standard-manifest.json": "7a626658a36a5ab612b7ed0be33773c9b0f2e2e5f80cc7244dcb962d4a4bc62d",
211
+ "spec/v1/core-standard-manifest.json": "5c3946728d3bb64d4cf290e93156788445da37272d4e44a4da803be695f9b543",
212
212
  "spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
213
213
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
214
214
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
215
215
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
216
216
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
217
217
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
218
- "spec/v1/gaps.json": "e99666a5b15814ee1e5a109924c992f3b15a4fdae70e3c1f0f790f2099137686",
218
+ "spec/v1/gaps.json": "e31dd0a2c440ff38469a8033e3324f372278ee94d209e0f84207d2111123f506",
219
219
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
220
220
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
221
221
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -313,9 +313,9 @@
313
313
  "spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
314
314
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
315
315
  "spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
316
- "spec/v2/release.json": "5a677aeedc38b41d42e2e8bd4dbe56f98cefc23673fd9f718787d22c176ef0fc",
316
+ "spec/v2/release.json": "1348d062c28c19897650cecb1f968c61adece3a61fdc4ecb8b7d8675468b7f65",
317
317
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
318
- "spec/v2/surface-baseline.json": "4e0350c79c6415b758386f19b7d52971d4bc27dd79984b2cf11d5906ca45df86"
318
+ "spec/v2/surface-baseline.json": "185c226ce4868f6d435275b1005890c9328c18f2f86163f4622cce8b0dec63e3"
319
319
  },
320
- "corpusCommit": "89ee5f47a9c41ffa3a1e12531492125e97bc7df9"
320
+ "corpusCommit": "b7f55b6d06df56983be5a9e9d518dd5a8fae8b29"
321
321
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * RFC 0243 G2: the `agents.fanOut` breadth and per-item witnesses, fixture
3
+ * runs through `POST /runs`, `events/poll` and `GET /runs/{runId}`. No seam:
4
+ * each fixture's supervisor carries a `mockDispatchPlan`, so the decision is
5
+ * the fixture's, not a model's.
6
+ *
7
+ * Waves (`spec/v2/facets/agents.schema.json` §fanOut `maxFanOut`: "the
8
+ * effective concurrency is `min(config.maxConcurrency ?? ∞, maxFanOut ?? ∞)`,
9
+ * children beyond it are dispatched in waves and none is dropped"), on
10
+ * `conformance-dispatch-fanout-waves` (three `conformance-delay` children,
11
+ * `maxConcurrency: 2`):
12
+ * the parallel path records one `dispatch.fanned-out` (`childCount` 3, any
13
+ * `maxConcurrency` within the effective ceiling) and one `dispatch.joined`
14
+ * (3 completed, none failed, `mergeOrder` of 3); three child runs complete;
15
+ * and no more than the ceiling of them are in flight at once, judged from
16
+ * each child's `startedAt`..`completedAt`.
17
+ *
18
+ * Per-item input (the facet's `perItemInput`; `orchestrator-decision.schema.json`
19
+ * `nextWorkerInputs`), on `conformance-dispatch-per-item` (one child workflow
20
+ * twice, items `item-a` and `item-b`):
21
+ * honored at `perItemInput: true`, two children are dispatched and
22
+ * their `inputs.childGreeting` are the two items;
23
+ * fail closed otherwise, the dispatch node fails `validation_error` and no
24
+ * child is dispatched.
25
+ * Length mismatch (`nextWorkerInputs`: "`nextWorkerInputs.length` MUST equal
26
+ * `nextWorkerIds.length`"), on `conformance-dispatch-per-item-mismatch`: on
27
+ * every dispatch host, the dispatch node fails `validation_error` and no child
28
+ * is dispatched.
29
+ *
30
+ * Fail closed is also read as satisfied by a `400 validation_error` at
31
+ * `createRun` with no run: either way no child exists, which is the hazard.
32
+ *
33
+ * The drivers observe and assert nothing; the judges are pure, proven in
34
+ * `fan-out-witness.test.ts`.
35
+ */
36
+
37
+ import { isFixtureAdvertised } from './fixtures.js';
38
+ import type { MajorProfile } from './major-profile.js';
39
+ import {
40
+ finding, isRecord, observed, observeRun, readRun, runCompleted, skip,
41
+ type Ev, type Finding, type ObservedRun, type ObserveOpts, type Outcome, type RunRead, type Skip,
42
+ } from './fixture-run-observer.js';
43
+
44
+ export const WAVES_FIXTURE = 'conformance-dispatch-fanout-waves';
45
+ export const WAVES_CHILD = 'conformance-delay';
46
+ export const PER_ITEM_FIXTURE = 'conformance-dispatch-per-item';
47
+ export const PER_ITEM_MISMATCH_FIXTURE = 'conformance-dispatch-per-item-mismatch';
48
+ export const PER_ITEM_CHILD = 'conformance-dispatch-input-mapping-child';
49
+ export const PER_ITEM_VALUES = ['item-a', 'item-b'] as const;
50
+ export const DISPATCH_NODE = 'dispatch';
51
+ /** The fixture's `maxConcurrency`. */
52
+ export const WAVES_MAX_CONCURRENCY = 2;
53
+ export const WAVES_CHILDREN = 3;
54
+ /**
55
+ * Two child windows that touch within this many milliseconds are not counted as
56
+ * overlapping: a host stamps a child's `completedAt` and the next child's
57
+ * `startedAt` from separate clocks reads. A real breach overlaps by a whole
58
+ * `conformance-delay` (1000 ms by default).
59
+ */
60
+ export const OVERLAP_TOLERANCE_MS = 100;
61
+
62
+ const DOC_FANOUT = 'agents.schema.json §fanOut';
63
+ const DOC_ITEMS = 'orchestrator-decision.schema.json nextWorkerInputs';
64
+
65
+ export interface FanOutObservation {
66
+ readonly run: ObservedRun;
67
+ /** Child run ids in dispatch order, from `node.dispatched` (else the join's `mergeOrder`). */
68
+ readonly childRunIds: readonly string[];
69
+ readonly children: readonly RunRead[];
70
+ /** `fannedType` / `joinedType` / `dispatchedType`: this major's event names. */
71
+ readonly types: { readonly fanned: string; readonly joined: string; readonly dispatched: string };
72
+ }
73
+
74
+ export interface WavesObservation extends FanOutObservation {
75
+ /** `min(maxConcurrency, maxFanOut ?? ∞)`. */
76
+ readonly ceiling: number;
77
+ }
78
+
79
+ export interface PerItemObservation extends FanOutObservation {
80
+ /** The host advertises `agents.fanOut.perItemInput: true`. */
81
+ readonly perItemInput: boolean;
82
+ /** The `createRun` body, for a refusal there. */
83
+ readonly createBody: unknown;
84
+ }
85
+
86
+ function eventTypes(profile: MajorProfile): Skip | FanOutObservation['types'] {
87
+ const fanned = profile.eventType('core.dispatch.fanOut');
88
+ const joined = profile.eventType('core.dispatch.join');
89
+ if (fanned === undefined || joined === undefined) return skip('blocked', 'the event codemap is not on disk in this layout — the suite will not guess an event name');
90
+ return { fanned, joined, dispatched: profile.eventType('node.dispatched') ?? 'node.dispatched' };
91
+ }
92
+
93
+ /** `agents.dispatch: true`, and the fixtures. */
94
+ function dispatchGate(profile: MajorProfile, doc: unknown, fixtures: readonly string[]): Skip | { readonly agents: Record<string, unknown> } {
95
+ const agents = profile.family(doc, 'agents');
96
+ if (agents === null || agents['dispatch'] !== true) return skip('inapplicable', 'the host does not advertise agents.dispatch: true, so core.dispatch does not bind it');
97
+ for (const id of fixtures) if (!isFixtureAdvertised(id)) return skip('inapplicable', `fixture ${id} is not advertised — the fixture is the opt-in`);
98
+ return { agents };
99
+ }
100
+
101
+ /** `agents.fanOut` admitting a `wait-all` join with `collect`, and the fixtures. */
102
+ export function wavesGate(profile: MajorProfile, doc: unknown): Skip | { ceiling: number } {
103
+ const g = dispatchGate(profile, doc, [WAVES_FIXTURE, WAVES_CHILD]);
104
+ if ('kind' in g) return g;
105
+ const f = g.agents['fanOut'];
106
+ if (!isRecord(f)) return skip('inapplicable', 'the host does not advertise agents.fanOut, so it runs no parallel fan-out (the race fixture witnesses its refusal)');
107
+ const has = (k: string, v: string): boolean => Array.isArray(f[k]) && (f[k] as unknown[]).includes(v);
108
+ if (!has('joinModes', 'wait-all') || !has('onChildFailureModes', 'collect')) return skip('inapplicable', 'agents.fanOut does not admit a wait-all join with collect, which this fixture pins');
109
+ const max = typeof f['maxFanOut'] === 'number' && f['maxFanOut'] >= 1 ? f['maxFanOut'] : Infinity;
110
+ return { ceiling: Math.min(WAVES_MAX_CONCURRENCY, max) };
111
+ }
112
+
113
+ /** `agents.dispatch: true` and the per-item fixture (or the mismatch fixture) plus its child. */
114
+ export function perItemGate(profile: MajorProfile, doc: unknown, fixture: string): Skip | { perItemInput: boolean } {
115
+ const g = dispatchGate(profile, doc, [fixture, PER_ITEM_CHILD]);
116
+ if ('kind' in g) return g;
117
+ const f = g.agents['fanOut'];
118
+ return { perItemInput: isRecord(f) && f['perItemInput'] === true };
119
+ }
120
+
121
+ /** The dispatched children of one workflow, in log order: `node.dispatched`, else the join's `mergeOrder`. */
122
+ export function locateChildren(events: readonly Ev[], types: FanOutObservation['types'], childWorkflowId: string): string[] {
123
+ const ids = events
124
+ .filter((e) => e.type === types.dispatched && e.payload['childWorkflowId'] === childWorkflowId && typeof e.payload['childRunId'] === 'string')
125
+ .map((e) => e.payload['childRunId'] as string);
126
+ if (ids.length > 0) return [...new Set(ids)];
127
+ const join = events.find((e) => e.type === types.joined);
128
+ const mo = join?.payload['mergeOrder'];
129
+ return Array.isArray(mo) ? [...new Set(mo.filter((x): x is string => typeof x === 'string'))] : [];
130
+ }
131
+
132
+ async function follow(profile: MajorProfile, workflowId: string, child: string, types: FanOutObservation['types'], opts: ObserveOpts): Promise<FanOutObservation & { createBody: unknown }> {
133
+ const run = await observeRun(profile, workflowId, { timeoutMs: 45_000, ...opts });
134
+ const childRunIds = locateChildren(run.events, types, child);
135
+ const children = await Promise.all(childRunIds.map((id) => readRun(profile, id)));
136
+ return { run, childRunIds, children, types, createBody: run.createBody };
137
+ }
138
+
139
+ /** Run the waves fixture and read its children. Asserts nothing. */
140
+ export async function driveWaves(profile: MajorProfile, doc: unknown, opts: ObserveOpts = {}): Promise<Skip | WavesObservation> {
141
+ const g = wavesGate(profile, doc);
142
+ if ('kind' in g) return g;
143
+ const types = eventTypes(profile);
144
+ if ('kind' in types) return types;
145
+ return { ...(await follow(profile, WAVES_FIXTURE, WAVES_CHILD, types, opts)), ceiling: g.ceiling };
146
+ }
147
+
148
+ /** Run a per-item fixture and read its children. Asserts nothing. */
149
+ export async function drivePerItem(profile: MajorProfile, doc: unknown, fixture: string, opts: ObserveOpts = {}): Promise<Skip | PerItemObservation> {
150
+ const g = perItemGate(profile, doc, fixture);
151
+ if ('kind' in g) return g;
152
+ const types = eventTypes(profile);
153
+ if ('kind' in types) return types;
154
+ return { ...(await follow(profile, fixture, PER_ITEM_CHILD, types, opts)), perItemInput: g.perItemInput };
155
+ }
156
+
157
+ const ofType = (o: FanOutObservation, t: string): Ev[] => o.run.events.filter((e) => e.type === t && (e.nodeId === undefined || e.nodeId === DISPATCH_NODE));
158
+ const time = (v: unknown): number | undefined => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? Date.parse(v) : undefined);
159
+
160
+ /** The largest number of `[start, end]` windows in flight at once, ignoring touches within `tolerance`. Pure. */
161
+ export function maxInFlight(windows: ReadonlyArray<readonly [number, number]>, tolerance = OVERLAP_TOLERANCE_MS): number {
162
+ if (windows.length === 0) return 0;
163
+ // A window counts from `tolerance` after its start; one shorter than that cannot overlap anything.
164
+ const points = windows.filter(([s, e]) => s + tolerance < e).flatMap(([s, e]) => [[s + tolerance, 1], [e, -1]] as const);
165
+ // At the same instant an end closes before a start opens, so touching windows never overlap.
166
+ points.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
167
+ let cur = 0;
168
+ let best = 1;
169
+ for (const [, d] of points) { cur += d; best = Math.max(best, cur); }
170
+ return best;
171
+ }
172
+
173
+ /** Leg: waves within the ceiling, none dropped. Pure. */
174
+ export function judgeWaves(o: WavesObservation): Outcome {
175
+ const fanned = ofType(o, o.types.fanned);
176
+ const joined = ofType(o, o.types.joined);
177
+ const f = fanned[0]?.payload ?? {};
178
+ const j = joined[0]?.payload ?? {};
179
+ const mc = f['maxConcurrency'];
180
+ const windows = o.children.map((c) => [time(c.snapshot['startedAt']), time(c.snapshot['completedAt'])] as const);
181
+ const timed = windows.every(([s, e]) => s !== undefined && e !== undefined);
182
+ const findings: Finding[] = [
183
+ ...runCompleted(o.run),
184
+ finding(fanned.length === 1, `${DOC_FANOUT} (run-event-payloads dispatchFanOut)`, `a parallel dispatch MUST record one ${o.types.fanned} (got ${fanned.length})`),
185
+ finding(f['childCount'] === WAVES_CHILDREN, `${DOC_FANOUT} (none is dropped)`, `${o.types.fanned}.childCount MUST be ${WAVES_CHILDREN}, every child the decision named (got ${JSON.stringify(f['childCount'])})`),
186
+ finding(mc === undefined || (typeof mc === 'number' && mc <= o.ceiling), `${DOC_FANOUT} (effective concurrency is min(maxConcurrency, maxFanOut))`, `${o.types.fanned}.maxConcurrency MUST NOT exceed ${o.ceiling} (got ${JSON.stringify(mc)})`),
187
+ finding(joined.length === 1, `${DOC_FANOUT} (run-event-payloads dispatchJoin)`, `the wait-all join MUST record one ${o.types.joined} (got ${joined.length})`),
188
+ finding(j['completedCount'] === WAVES_CHILDREN && j['failedCount'] === 0, `${DOC_FANOUT} (none is dropped)`, `${o.types.joined} MUST count ${WAVES_CHILDREN} completed and 0 failed (got ${JSON.stringify({ completedCount: j['completedCount'], failedCount: j['failedCount'] })})`),
189
+ finding(Array.isArray(j['mergeOrder']) && j['mergeOrder'].length === WAVES_CHILDREN, `${DOC_FANOUT} (run-event-payloads dispatchJoin.mergeOrder)`, `${o.types.joined}.mergeOrder MUST list all ${WAVES_CHILDREN} children (got ${JSON.stringify(j['mergeOrder'])})`),
190
+ finding(o.childRunIds.length === WAVES_CHILDREN, `${DOC_FANOUT} (none is dropped)`, `${WAVES_CHILDREN} distinct ${WAVES_CHILD} child runs MUST be dispatched (found ${o.childRunIds.length})`),
191
+ ...o.children.map((c, i) => finding(c.status === 200 && c.snapshot['status'] === 'completed', `fixtures.md ${WAVES_CHILD}`, `child run ${o.childRunIds[i]} MUST be readable and end completed (GET ${c.status}, status ${JSON.stringify(c.snapshot['status'])})`)),
192
+ ];
193
+ if (timed && o.children.length > 0) {
194
+ const n = maxInFlight(windows as ReadonlyArray<readonly [number, number]>);
195
+ findings.push(finding(n <= o.ceiling, `${DOC_FANOUT} (children beyond the ceiling are dispatched in waves)`, `no more than ${o.ceiling} children may be in flight at once (${n} overlapped by more than ${OVERLAP_TOLERANCE_MS} ms)`));
196
+ }
197
+ return observed(findings);
198
+ }
199
+
200
+ /** The dispatch node failed `validation_error` and no child exists — at run time, or by a `400` at `createRun`. Pure. */
201
+ export function judgeFailClosed(o: FanOutObservation & { createBody: unknown }, doc: string, why: string): Outcome {
202
+ const r = o.run;
203
+ if (r.createStatus === 400) {
204
+ const err = isRecord(o.createBody) ? o.createBody['error'] : undefined;
205
+ return observed([finding(err === 'validation_error', doc, `${why}: a refusal at createRun MUST be 400 validation_error (got ${JSON.stringify(err)})`)]);
206
+ }
207
+ const failed = r.events.filter((e) => e.type === 'node.failed' && e.nodeId === DISPATCH_NODE);
208
+ const code = isRecord(failed[0]?.payload['error']) ? failed[0]!.payload['error']['code'] : undefined;
209
+ return observed([
210
+ finding(r.createStatus === 201 || r.createStatus === 202, 'runs.md createRun', `createRun of ${r.workflowId} MUST be accepted or refused 400 validation_error (got ${r.createStatus})`),
211
+ finding(code === 'validation_error', doc, `${why}: the dispatch node MUST fail with validation_error (node.failed ${failed.length === 0 ? 'absent' : `code ${JSON.stringify(code)}`})`),
212
+ finding(o.childRunIds.length === 0 && ofType(o, o.types.dispatched).length === 0, doc, `${why}: no child may be dispatched (found ${o.childRunIds.length})`),
213
+ finding(r.terminalStatus === 'failed', doc, `the run MUST end failed (got ${String(r.terminalStatus)})`),
214
+ ]);
215
+ }
216
+
217
+ /** Leg: at `perItemInput: true` each child receives its own item; otherwise fail closed. Pure. */
218
+ export function judgePerItem(o: PerItemObservation): Outcome {
219
+ if (!o.perItemInput) return judgeFailClosed(o, `${DOC_FANOUT} perItemInput; ${DOC_ITEMS}`, 'a host without agents.fanOut.perItemInput: true receiving non-empty nextWorkerInputs');
220
+ const got = o.children.map((c) => (isRecord(c.snapshot['inputs']) ? c.snapshot['inputs']['childGreeting'] : undefined));
221
+ const sorted = [...got].map(String).sort();
222
+ return observed([
223
+ ...runCompleted(o.run),
224
+ finding(o.childRunIds.length === PER_ITEM_VALUES.length, `${DOC_ITEMS} (index-aligned with nextWorkerIds)`, `${PER_ITEM_VALUES.length} ${PER_ITEM_CHILD} child runs MUST be dispatched (found ${o.childRunIds.length})`),
225
+ ...o.children.map((c, i) => finding(c.status === 200, 'runs.md getRun', `child run ${o.childRunIds[i]} MUST be readable (GET ${c.status})`)),
226
+ finding(JSON.stringify(sorted) === JSON.stringify([...PER_ITEM_VALUES]), `${DOC_FANOUT} perItemInput; ${DOC_ITEMS}`, `each child MUST receive its own nextWorkerInputs[i]: inputs.childGreeting MUST be ${JSON.stringify(PER_ITEM_VALUES)} across the children (got ${JSON.stringify(got)})`),
227
+ ]);
228
+ }
229
+
230
+ /** Leg: a length mismatch fails the dispatch node and dispatches no child. Pure. */
231
+ export function judgeLengthMismatch(o: PerItemObservation): Outcome {
232
+ return judgeFailClosed(o, DOC_ITEMS, 'nextWorkerInputs.length differs from nextWorkerIds.length');
233
+ }
@@ -35,6 +35,8 @@ export interface RunRead { readonly status: number; readonly snapshot: Record<st
35
35
  export interface ObservedRun {
36
36
  readonly workflowId: string;
37
37
  readonly createStatus: number;
38
+ /** The `createRun` response body. */
39
+ readonly createBody?: unknown;
38
40
  readonly runId: string | undefined;
39
41
  readonly terminalStatus: string | undefined;
40
42
  /** The last `GET /runs/{runId}` body. */
@@ -78,11 +80,11 @@ export async function observeRun(profile: MajorProfile, workflowId: string, opts
78
80
  const created = await driver.post(profile.runsPath, { workflowId });
79
81
  const runId = isRecord(created.json) && typeof created.json['runId'] === 'string' ? created.json['runId'] : undefined;
80
82
  if ((created.status !== 201 && created.status !== 202) || runId === undefined) {
81
- return { workflowId, createStatus: created.status, runId: undefined, terminalStatus: undefined, snapshot: {}, events: [] };
83
+ return { workflowId, createStatus: created.status, createBody: created.json, runId: undefined, terminalStatus: undefined, snapshot: {}, events: [] };
82
84
  }
83
85
  const r = await pollTerminal(profile, runId, opts);
84
86
  const status = r.snapshot['status'];
85
- return { workflowId, createStatus: created.status, runId, terminalStatus: typeof status === 'string' ? status : undefined, snapshot: r.snapshot, events: await readEvents(profile, runId) };
87
+ return { workflowId, createStatus: created.status, createBody: created.json, runId, terminalStatus: typeof status === 'string' ? status : undefined, snapshot: r.snapshot, events: await readEvents(profile, runId) };
86
88
  }
87
89
 
88
90
  /** The create was accepted and the fixture run ended `completed`, as its catalog entry says. */
@@ -30,6 +30,10 @@ export interface DoubleRun {
30
30
  readonly status: string;
31
31
  readonly variables?: Record<string, unknown> | undefined;
32
32
  readonly inputs?: Record<string, unknown> | undefined;
33
+ /** Further snapshot members (`startedAt`, `completedAt`, …). */
34
+ readonly snapshot?: Record<string, unknown> | undefined;
35
+ /** Refuse the create with this answer instead of making the run. */
36
+ readonly refuse?: { readonly status: number; readonly body: unknown } | undefined;
33
37
  readonly events?: readonly DoubleEvent[];
34
38
  /** The `GET /runs/{runId}/ancestry` answer, given the run's own id. Unset: 404. */
35
39
  readonly ancestry?: ((self: string) => { readonly status: number; readonly body: unknown }) | undefined;
@@ -103,6 +107,7 @@ export class RunGraphDouble {
103
107
  const self = this.mint();
104
108
  const run = this.opts.onCreate?.(workflowId, (child) => { const id = this.mint(); this.runs.set(id, child); return id; }, self);
105
109
  if (run === undefined) return this.send(res, 404, { error: 'not_found', message: `no workflow ${workflowId}` });
110
+ if (run.refuse !== undefined) return this.send(res, run.refuse.status, run.refuse.body);
106
111
  this.runs.set(self, { workflowId, ...run });
107
112
  return this.send(res, 201, { runId: self, status: 'running' });
108
113
  }
@@ -128,6 +133,7 @@ export class RunGraphDouble {
128
133
  runId: id, workflowId: run.workflowId ?? 'double', status: run.status,
129
134
  ...(run.variables === undefined ? {} : { variables: run.variables }),
130
135
  ...(run.inputs === undefined ? {} : { inputs: run.inputs }),
136
+ ...(run.snapshot ?? {}),
131
137
  });
132
138
  }
133
139
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * v2 — `agents.fanOut` breadth and per-item input (RFC 0243 §A.3, §A.4; gap
3
+ * G2). The facet (`spec/v2/facets/agents.schema.json` §fanOut) states both:
4
+ * "children beyond [`maxFanOut`] are dispatched in waves and none is dropped",
5
+ * and "without [`perItemInput: true`], a non-empty `nextWorkerInputs` MUST fail
6
+ * the dispatch node with `validation_error` and dispatch no child".
7
+ * `orchestrator-decision.schema.json` `nextWorkerInputs` adds the length rule.
8
+ * The v1 twins are `dispatch-fanout-parallel` and `dispatch-per-item-input`,
9
+ * which drive a v1 seam; these legs need none. Each fixture's supervisor
10
+ * carries a `mockDispatchPlan`, and the legs live in `lib/fan-out-witness.ts`.
11
+ *
12
+ * waves `conformance-dispatch-fanout-waves`: three `conformance-delay`
13
+ * children under `maxConcurrency: 2` all complete, the
14
+ * fan-out and join events count three, and no more than
15
+ * `min(2, maxFanOut)` children are in flight at once;
16
+ * per-item `conformance-dispatch-per-item`: at `perItemInput: true` each
17
+ * child receives its own item; otherwise the dispatch node
18
+ * fails `validation_error` and no child is dispatched;
19
+ * mismatch `conformance-dispatch-per-item-mismatch`: one item for two
20
+ * children fails the dispatch node `validation_error`, no child.
21
+ *
22
+ * Dispositions: `agents.dispatch` not `true`, or a fixture (or its child) not
23
+ * in `fixtures[]` ⇒ `inapplicable`; for waves, `agents.fanOut` absent or not
24
+ * admitting `wait-all` with `collect` ⇒ `inapplicable`; discovery unreadable ⇒
25
+ * `blocked`.
26
+ *
27
+ * Proven against doubles in `lib/fan-out-witness.test.ts`.
28
+ *
29
+ * @see spec/v2/facets/agents.schema.json §fanOut
30
+ * @see schemas/v2/orchestrator-decision.schema.json §nextWorkerInputs
31
+ * @see RFCS/0243-dispatch-fan-out-seat.md
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { v2Discovery } from '../lib/v2.js';
36
+ import { softSkip } from '../lib/soft-skip.js';
37
+ import { req } from '../lib/requirement-ids.js';
38
+ import { majorProfile } from '../lib/major-profile.js';
39
+ import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
40
+ import {
41
+ PER_ITEM_FIXTURE, PER_ITEM_MISMATCH_FIXTURE, drivePerItem, driveWaves, judgeLengthMismatch, judgePerItem, judgeWaves,
42
+ type PerItemObservation, type WavesObservation,
43
+ } from '../lib/fan-out-witness.js';
44
+
45
+ const PROFILE = majorProfile(2);
46
+ const ID_WAVES = 'openwop.requirement.0243.max-fan-out-waves';
47
+ const ID_PER_ITEM = 'openwop.requirement.0243.per-item-input-gated';
48
+ const ID_MISMATCH = 'openwop.requirement.agents.dispatch-per-item-length-mismatch';
49
+
50
+ async function discovery(): Promise<Record<string, unknown> | (() => undefined)> {
51
+ if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
52
+ let doc: Record<string, unknown> | null;
53
+ try { doc = await v2Discovery(); } catch { doc = null; }
54
+ return doc ?? (() => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'));
55
+ }
56
+ function assert(id: string, out: Outcome): undefined {
57
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
58
+ for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
59
+ return undefined;
60
+ }
61
+ const unwrap = <T,>(o: Skip | T): T | (() => undefined) => (o !== null && typeof o === 'object' && 'kind' in o && (o as Skip).kind === 'skip' ? () => softSkip((o as Skip).disposition, (o as Skip).reason) : (o as T));
62
+
63
+ describe('v2 agents.fanOut: waves and per-item input (RFC 0243 §A.3, §A.4)', () => {
64
+ it('children beyond the effective ceiling are dispatched in waves, and none is dropped', async () => {
65
+ const doc = await discovery();
66
+ if (typeof doc === 'function') return doc();
67
+ const o = unwrap<WavesObservation>(await driveWaves(PROFILE, doc));
68
+ if (typeof o === 'function') return o();
69
+ return assert(ID_WAVES, judgeWaves(o));
70
+ }, 90_000);
71
+
72
+ it('nextWorkerInputs reaches each child at perItemInput: true, and fails closed without it', async () => {
73
+ const doc = await discovery();
74
+ if (typeof doc === 'function') return doc();
75
+ const o = unwrap<PerItemObservation>(await drivePerItem(PROFILE, doc, PER_ITEM_FIXTURE));
76
+ if (typeof o === 'function') return o();
77
+ return assert(ID_PER_ITEM, judgePerItem(o));
78
+ }, 90_000);
79
+
80
+ it('a nextWorkerInputs length that differs from nextWorkerIds fails the dispatch node and dispatches no child', async () => {
81
+ const doc = await discovery();
82
+ if (typeof doc === 'function') return doc();
83
+ const o = unwrap<PerItemObservation>(await drivePerItem(PROFILE, doc, PER_ITEM_MISMATCH_FIXTURE));
84
+ if (typeof o === 'function') return o();
85
+ return assert(ID_MISMATCH, judgeLengthMismatch(o));
86
+ }, 90_000);
87
+ });