@openwop/openwop-conformance 2.46.4 → 2.46.6

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,17 @@
1
+ ## [2.46.6] — 2026-10-13 — RFC 0243 G2: fan-out waves and per-item input
2
+
3
+ - **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`.
4
+ - `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`.
5
+ - `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).
6
+ - `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.
7
+ - **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`.
8
+ - `ObservedRun` gains an optional `createBody`; the run-graph double can refuse a create and add snapshot members.
9
+
10
+ ## [2.46.5] — 2026-10-13 — RFC 0243 Accepted; a fan-out prediction corrected
11
+
12
+ - **No assertion changes.** This cycle opens because RFC 0243's gap G1 closes, which changes the packed `spec/v1/gaps.json`.
13
+ - **Correction to the [2.46.4] notes.** They said both fan-out rows are `inapplicable` everywhere. On a host advertising `agents.dispatch: true` that seeds and lists every fixture the package ships, `conformance-dispatch-fanout-race` is listed, so `0243.unadmitted-fan-out-refused` runs. It records `executed-fail` unless the host advertises `agents.fanOut` with the modes it runs or refuses the workflow at `POST /runs` with `400 validation_error` and `details.requiredCapability`. Leaving the fixture out of `fixtures` keeps the row `inapplicable`. The rule did not change; only the prediction was wrong.
14
+
1
15
  # `@openwop/openwop-conformance` Changelog
2
16
 
3
17
  ## [2.46.4] — 2026-10-12 — RFC 0243: parallel fan-out has a v2 seat, and a link key needs both linking lanes
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.4",
4
- "stampSha256": "81bd0894e7ad0d539784a6fb3ac038bddd46d7da22870e6da68295c1df0f2eee"
3
+ "version": "2.46.6",
4
+ "stampSha256": "4b005727a8561e054a7c97bb1466977ff703c6144ddf92da413c5875249b5495"
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.4",
3
+ "version": "2.46.6",
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.4"
59
+ "@openwop/spec-artifacts": "2.46.6"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,9 +2,9 @@
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
9
  "explicitIds": 2653
10
10
  },
@@ -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": null,
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": null,
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": null,
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",
@@ -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.4",
5
- "corpusTag": "v2.46.4",
4
+ "version": "2.46.6",
5
+ "corpusTag": "v2.46.6",
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": "657ca41ba3259b0e9a1d466e025ec4aeb56cacda1f218bd0f7800f0c3584ec6b",
13
- "api/v2/asyncapi.yaml": "438b60c9f0d42ddee5973ee135522de8de6dea6f344b929a9c33b5f9f8c18568",
14
- "api/v2/openapi.yaml": "feecb1ff541e9fcb32731a63d97f0b3748ded7b290ed336b7fff92f28d6fc2ae",
12
+ "api/seams-v2.yaml": "48ca51cc211127aed939340f5a39232d9f938037081d88ce9107f0929982d3c5",
13
+ "api/v2/asyncapi.yaml": "4a5c1f6c99d08e41883637b2ea4a663687cf1d916d879cc490e6836ed7a0051f",
14
+ "api/v2/openapi.yaml": "c4ca7bae3a66460097646432792d54146891b433f7dab62e4dcceb857738ae4a",
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": "a716a260a5226f365c4467486c06e0166d677f77210d9f75e9d8576cfcabae82",
211
+ "spec/v1/core-standard-manifest.json": "4375f41b350294f0cc2a045176389f459452af3708a1dd408df49293d5a823bd",
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": "0a9d1b046391d3789a53dee8a3aba8dcc50037b87a5fad8fd5e8a051176c12ad",
218
+ "spec/v1/gaps.json": "e99666a5b15814ee1e5a109924c992f3b15a4fdae70e3c1f0f790f2099137686",
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": "0b8a78d84b40c96afe63c4306283872ffe76e49afd79155e805dbd25985a9d25",
316
+ "spec/v2/release.json": "84cecdf093e65301c2c4516a2665e9806d15ea82c50b4368888b918214254bb0",
317
317
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
318
- "spec/v2/surface-baseline.json": "40b2b2ee581c614c1d5adf5a11d6a76a8188d5ce4518aca59a2b7221b35b3e61"
318
+ "spec/v2/surface-baseline.json": "78eff58bd5ea5ab23968f840d886c3e78c280ff4f2ca235c792625446095b60a"
319
319
  },
320
- "corpusCommit": "c59d856a6bf3124d5e1fe97e51ba47ad54c5d502"
320
+ "corpusCommit": "213b2eafb1ed4cfaa71bcb088fb86f520b8688bf"
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
+ });