| Field | Value |
|---|---|
| RFC | 0118 |
| Title | Parallel sub-workflow fan-out and join (fanOutPolicy: 'parallel' + joinPolicy) |
| Status | Accepted |
| Author(s) | David Tufts (@davidscotttufts) |
| Created | 2026-06-27 |
| Updated | 2026-06-28 (Active → Accepted — register sweep complete, G1–G9 (see …gaps.md §"Register sweep"). Graduated on dual-witness discovery/advertisement evidence, steward-curl-verified 2026-06-28: openwop-app reference host (prod, app.openwop.dev/api) advertises dispatch { fanOutSupported:true, fanOutPolicies:["sequential","reject","parallel"], joinModes:["wait-all"], onChildFailureModes:["collect","absorb"], maxFanOut:16 }; MyndHyve workflow-runtime (tier-2 steward-affiliated sibling, seam-enabled rev 00513-qib from main@66fc108e, --no-traffic --tag conf-fanout) advertises the full joinModes:["wait-all","quorum","first","race"] + onChildFailureModes:["collect","fail-fast","absorb"] — the honest per-host subset divergence the capability gating exists to surface. Behavioral evidence: dual-witness, steward-verified 2026-06-28 (the cross-check deferred at the flip was subsequently run — both hosts stood up auth-exempt conf-fanout seam revisions, so no key was needed): steward direct probe of each host's POST /v1/host/sample/dispatch/fanout (wait-all/collect ×3) returned a real non-vacuous coordinator+fold — openwop-app tag rev 00355-zag and MyndHyve tag rev 00513-qib each → { joinOutcome:"satisfied", children:[3×completed], mergeOrder:[3], completedCount:3, failedCount:0 }; corroborated by MyndHyve's own dispatch-fanout-parallel.test.ts 10/10 (@openwop/openwop-conformance@1.45.0, OPENWOP_REQUIRE_BEHAVIOR=true). The full conformance harness against openwop-app's tag URL tripped that host's per-IP 429 read budget during its discovery burst and soft-skipped the gated legs, so the direct seam probe is the steward behavioral artifact for that host. openwop-app's prod seam stays dark by posture (RFC 0117 parity); the always-on schema legs pass server-free. Per GOVERNANCE.md §"Acceptance evidence tiers" this is a steward-affiliated (tier-2) + reference-host graduation, recorded honestly rather than as independent-org dual-witness.) 2026-06-27 (Draft → Active — steward waiver of the 7-day additive comment window per GOVERNANCE.md lazy consensus; all five Unresolved questions resolved in-RFC because Active locks the wire shape) |
| Affects | schemas/dispatch-config.schema.json, schemas/run-event-payloads.schema.json, spec/v1/node-packs.md (core.dispatch / core.subWorkflow fan-out semantics), spec/v1/capabilities.md (capabilities.dispatch.), api/asyncapi.yaml, conformance dispatch- scenarios |
| Compatibility | additive per COMPATIBILITY.md |
| Supersedes | — |
| Superseded by | — |
Summary
RFC 0007 §K3 explicitly defers fanOutPolicy: 'parallel' "to v1.2 with a join-policy field," leaving core.dispatch (and the core.subWorkflow machinery it drives) able to fan out to multiple child workflows only sequentially — each child blocks the next. This RFC closes that deferral: it adds 'parallel' to the fanOutPolicy enum, introduces a normative joinPolicy object that pins error-aggregation and completion semantics across concurrent children, adds a bounded-concurrency control, and specifies the per-child lifecycle events a parallel fan-out emits. The change is purely additive — the default stays 'sequential', the new fields are optional, and hosts that do not advertise capabilities.dispatch.fanOutSupported: true reject the new policy at registration time exactly as they do today.
Motivation
The dispatch model is the wire's only portable way for one run to drive several child runs. Today that fan-out is serialized: §D step 2 dispatches "the first ID (or all IDs sequentially per fanOutPolicy)," and §K3 names parallel fan-out as the open question, gated only by the lack of an agreed join policy (does one child failure fail all, or is it quorum, or wait-all-then-report?).
Real orchestrations fan out by design. The motivating implementer is a multi-channel marketing campaign workflow (openwop-app's Campaign Studio port, docs/campaign-studio-prd.md): a parent run selects brand/persona/knowledge-base assets, generates a shared messaging kernel, then generates five channel deliverables concurrently — landing page, ad variants, email sequence, creative briefs, social posts — each as an independent child workflow (generate → quality-check → brand-compliance → approval), joining on all five before a cross-asset consistency check. Forced to dispatch sequentially, a campaign that should take ~5 minutes (slowest channel) takes ~25 (sum of channels), and a single channel's approval stall blocks every other channel from even starting. The same shape recurs in fan-out research, multi-vendor RFQ, batch document processing, and any map-style workload.
Hosts that want this today must either (a) collapse the children into one monolithic child workflow (losing the per-child run lineage, independent approval gates, and replay isolation that core.subWorkflow gives), or (b) invent a vendor.<host>.parallel dispatch model — which is exactly the cross-host portability break RFC 0007 §Motivation warns against. A workflow authored against one host's parallel convention will not run on another. The join semantics are a wire contract, so the spec is the right place to fix this.
This RFC is deliberately scoped to fan-out breadth (concurrent siblings + join). It does NOT reopen §K1 (same-run DAG navigation) or §K2 streaming dispatch outputs, except where §K2's per-child progress visibility is a necessary consequence of running children concurrently (see §E).
Proposal
§A fanOutPolicy: 'parallel'
Extend the DispatchConfig.fanOutPolicy enum (dispatch-config.schema.json) with a third value, 'parallel'. When decision.kind === 'next-worker' and nextWorkerIds.length > 1:
'sequential'(default, unchanged) — dispatch each child in array order, blocking on each terminal before the next.'reject'(unchanged) — fail with'fan_out_unsupported'.'parallel'(new) — dispatch allnextWorkerIds[i]as child runs concurrently (subject tomaxConcurrency, §C) and join on their terminals perjoinPolicy(§B).
A host that does not advertise capabilities.dispatch.fanOutSupported: true (§F) MUST surface a validation_error at POST /v1/workflows when a core.dispatch node pins fanOutPolicy: 'parallel'. This mirrors the existing workerDispatchModel / askUserRouting registration-time validation in RFC 0007 §H.
"fanOutPolicy": {
"type": "string",
- "enum": ["sequential", "reject"],
+ "enum": ["sequential", "reject", "parallel"],
"default": "sequential",
- "description": "... Parallel fan-out is OUT OF SCOPE for v1.x — see RFC 0007 §K3 (unresolved question)."
+ "description": "... `'parallel'` (RFC 0118) dispatches all `nextWorkerIds[i]` concurrently (bounded by `maxConcurrency`) and joins on their terminals per `joinPolicy`. Requires `capabilities.dispatch.fanOutSupported: true`; rejected with `validation_error` at registration otherwise."
},
§B joinPolicy
Add an optional joinPolicy object to DispatchConfig. It is meaningful only when fanOutPolicy: 'parallel'; a host MUST surface a validation_error at registration if joinPolicy is present while fanOutPolicy !== 'parallel'.
+ "joinPolicy": {
+ "type": "object",
+ "description": "RFC 0118. Completion + error-aggregation semantics for `fanOutPolicy: 'parallel'`. Ignored (and a registration `validation_error`) for any other fan-out policy.",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["wait-all", "quorum", "first", "race"],
+ "default": "wait-all"
+ },
+ "quorum": { "type": "integer", "minimum": 1 },
+ "onChildFailure": {
+ "type": "string",
+ "enum": ["fail-fast", "absorb", "collect"],
+ "default": "collect"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ },
mode — when the join is considered satisfied:
'wait-all'(default) — the dispatch node remains suspended until every dispatched child reaches a terminal state (completed,failed, orcancelled). MUST be the default because it is the only mode that never discards a child's outcome.'quorum'— satisfied when at leastquorumchildren reachcompleted. REQUIRES thequorumfield (1 ≤ quorum ≤ nextWorkerIds.length); a missing or out-of-rangequorumis a registrationvalidation_error. Children still in flight when quorum is reached are cancelled per the cascade ininterrupt-profiles.md§Parent-child cascade.'first'— satisfied when the first child reachescompleted. Remaining children are cancelled. ('first'is'quorum'withquorum: 1plus a fixed result-selection rule; it is kept distinct for authoring clarity.)'race'— satisfied when the first child reaches any terminal state (completedorfailed). Remaining children are cancelled. Distinguished from'first'for workloads where the earliest signal — success or failure — is the answer.
onChildFailure — how a non-completed child terminal affects the dispatch node. This generalizes the existing single-child core.subWorkflow.onChildFailure (fail-parent / absorb, per node-packs.md) to the N-child case:
'collect'(default) — no individual failure short-circuits the join; the node waits permode, then reports per-child outcomes in its output (§D). The parent workflow author decides what a partial failure means via downstream edge conditions. This is the safest default for fan-out work where channels are independent.'fail-fast'— the first child to reachfailed(orcancelled) immediately fails the dispatch node; remaining children are cancelled. Equivalent to thefail-parentsemantics applied at first failure.'absorb'— failed children are recorded in the run log and in the node output but never fail the parent; the join still completes permode. Equivalent to single-childabsorbapplied per child.
mode and onChildFailure are orthogonal: e.g. { mode: 'wait-all', onChildFailure: 'collect' } (the campaign case — wait for all five channels, surface which failed) versus { mode: 'quorum', quorum: 3, onChildFailure: 'fail-fast' } (need 3 of N, abort early on any hard failure).
§C maxConcurrency
Add an optional maxConcurrency integer to DispatchConfig, meaningful only under fanOutPolicy: 'parallel':
+ "maxConcurrency": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "RFC 0118. Maximum number of children dispatched concurrently under `fanOutPolicy: 'parallel'`. When `nextWorkerIds.length` exceeds this, the host dispatches in waves of at most `maxConcurrency`, starting a queued child as each in-flight child terminates. Absent → host-defined effective concurrency (a host MAY cap to protect itself; it MUST advertise that cap as `capabilities.dispatch.maxFanOut` so authors can plan). Does not change join semantics — `joinPolicy` still evaluates over the full `nextWorkerIds` set."
+ },
A host MAY enforce its own ceiling regardless of the authored value; the effective concurrency is min(maxConcurrency ?? ∞, capabilities.dispatch.maxFanOut ?? ∞). The host MUST NOT silently drop children above the ceiling — it queues and dispatches them in waves.
§D Output shape
Under fanOutPolicy: 'parallel', the dispatch node's output replaces the single-child { childRunId, childStatus } with a children array plus an aggregate joinOutcome. Sequential and reject policies are unchanged.
{
"outputs": {
"joinOutcome": "satisfied" | "failed" | "partial",
"children": [
{ "workflowId": "campaign-studio.channel.landing-page", "childRunId": "run-...", "childStatus": "completed" },
{ "workflowId": "campaign-studio.channel.ad-variants", "childRunId": "run-...", "childStatus": "completed" },
{ "workflowId": "campaign-studio.channel.email-sequence","childRunId": "run-...", "childStatus": "failed", "error": { "code": "..." } }
]
}
}
joinOutcome: 'satisfied'— themodecondition was met andonChildFailuredid not fail the node.joinOutcome: 'failed'—onChildFailure: 'fail-fast'tripped, ormodecould not be satisfied (e.g.quorum: 3but only 2 children completed). The dispatch node fails and the run transitions tofailedper existing propagation.joinOutcome: 'partial'—modesatisfied but at least one child is non-completedunderonChildFailure: 'collect'/'absorb'. The node SUCCEEDS; downstream authoring decides handling.
Output mapping (RFC 0022 outputMapping / perWorkerOutputMappings) applies per child: each completed child's mapped variables merge into the parent. When two children map to the same parent variable, the last terminal in wall-clock order wins, and the host MUST emit the merge order in the event log (§E) so replay is deterministic. Authors SHOULD avoid colliding output keys across parallel children.
§E Per-child lifecycle events
A parallel fan-out MUST emit, for each dispatched child, the same node.dispatched RunEvent ({ childRunId, childWorkflowId, childStatus }, run-event-payloads.schema.json $defs.nodeDispatched) that sequential dispatch emits today (RFC 0007 §D5). Concurrency makes the relative ordering of these events non-deterministic across runs; consumers MUST NOT assume array order.
To keep the parent observable while children run concurrently (the §K2 concern, resolved only for the parallel case), the host MUST emit a core.dispatch.fanOut event when the wave begins and a core.dispatch.join event when the join is satisfied:
"$defs": {
+ "dispatchFanOut": {
+ "type": "object",
+ "required": ["fanOutPolicy", "childCount"],
+ "properties": {
+ "fanOutPolicy": { "const": "parallel" },
+ "childCount": { "type": "integer", "minimum": 2 },
+ "maxConcurrency": { "type": "integer", "minimum": 1 },
+ "joinMode": { "type": "string", "enum": ["wait-all", "quorum", "first", "race"] }
+ },
+ "additionalProperties": false
+ },
+ "dispatchJoin": {
+ "type": "object",
+ "required": ["joinOutcome", "completedCount", "failedCount", "mergeOrder"],
+ "properties": {
+ "joinOutcome": { "type": "string", "enum": ["satisfied", "failed", "partial"] },
+ "completedCount": { "type": "integer", "minimum": 0 },
+ "failedCount": { "type": "integer", "minimum": 0 },
+ "cancelledCount": { "type": "integer", "minimum": 0 },
+ "mergeOrder": { "type": "array", "items": { "type": "string" }, "description": "childRunIds in the wall-clock terminal order used for output merging — the replay-deterministic tiebreak for colliding outputMapping keys." }
+ },
+ "additionalProperties": false
+ },
Both events carry the dispatching core.dispatch node-id in the envelope nodeId and set causationId to the consumed runOrchestrator.decided event (RFC 0007 §E), so replay reconstructs decision → fan-out → per-child → join even when concurrent runs interleave.
§F Capability advertisement
RFC 0007 §G already reserves capabilities.dispatch.fanOutSupported (currently false for all conformant hosts). This RFC gives it meaning and adds two descriptors:
{
"capabilities": {
"dispatch": {
"supported": true,
"models": ["child-run"],
- "fanOutSupported": false,
+ "fanOutSupported": true,
+ "fanOutPolicies": ["sequential", "reject", "parallel"],
+ "joinModes": ["wait-all", "quorum", "first", "race"],
+ "maxFanOut": 16,
"askUserRoutings": ["conversation", "clarification", "auto"]
}
}
}
fanOutSupported: trueis the gate for acceptingfanOutPolicy: 'parallel'at registration.fanOutPolicies/joinModeslet an author detect partial support (a host MAY shipparallel+wait-allonly and omitquorum). Acore.dispatchnode pinning ajoinPolicy.modenot injoinModesis a registrationvalidation_error.maxFanOutis the host's hard concurrency/breadth ceiling (§C). Absent → unbounded (authors SHOULD treat absent as "unknown, may be capped").
Hosts that advertise dispatch.supported: true but not fanOutSupported: true are fully conformant — parallel is opt-in.
§G Replay determinism
Parallel dispatch is non-deterministic at first execution (children race) but MUST be deterministic on replay: the host re-folds node.dispatched, core.dispatch.fanOut, per-child terminals, and core.dispatch.join (including mergeOrder) from the event log without re-invoking children. The mergeOrder field (§E) is the canonical record of the output-merge tiebreak; a replay MUST apply outputMapping in mergeOrder, not in nextWorkerIds order. This makes a forked or replayed run reproduce the original's parent-variable state exactly even though the original children completed in a wall-clock order that a replay does not re-observe.
§H Examples
Positive — the campaign five-channel fan-out (wait-all, collect):
{
"nodeId": "channel-dispatch",
"typeId": "core.dispatch",
"config": {
"workerDispatchModel": "child-run",
"fanOutPolicy": "parallel",
"maxConcurrency": 5,
"joinPolicy": { "mode": "wait-all", "onChildFailure": "collect" },
"inputMapping": { "briefId": "briefId", "workspaceId": "workspaceId" }
}
}
Positive — quorum with fail-fast (need 3 of N vendor quotes, abort on hard error):
{
"config": {
"fanOutPolicy": "parallel",
"joinPolicy": { "mode": "quorum", "quorum": 3, "onChildFailure": "fail-fast" }
}
}
Negative — joinPolicy without parallel (registration validation_error):
{ "config": { "fanOutPolicy": "sequential", "joinPolicy": { "mode": "wait-all" } } }
Negative — quorum mode missing the quorum field (registration validation_error):
{ "config": { "fanOutPolicy": "parallel", "joinPolicy": { "mode": "quorum" } } }
Negative — parallel without the capability (registration validation_error on a host advertising fanOutSupported: false):
{ "config": { "fanOutPolicy": "parallel", "joinPolicy": { "mode": "wait-all" } } }
Compatibility
Additive. Lands in v1.2 (the version RFC 0007 §K3 names).
fanOutPolicygains an enum value; existing'sequential'/'reject'workflows are byte-identical and behave identically. The default is unchanged.joinPolicy,maxConcurrencyare new optional fields with documented defaults (wait-all/collect, host-defined concurrency). A pre-RFC workflow omits them; a pre-RFC host that never seesparallelnever evaluates them.capabilities.dispatch.fanOutSupportedalready exists; flipping it totrueis an opt-in advertisement.fanOutPolicies,joinModes,maxFanOutare new optional capability descriptors that existing capability consumers ignore.- New event
$defs(dispatchFanOut,dispatchJoin) are additive; consumers that don't recognize them ignore them per the wire's forward-compat rule. They are emitted only on the parallel path, so a host that never runs a parallel dispatch never emits them. - No required field is added, removed, or retyped on any existing schema; no error code or HTTP status changes meaning. No existing v1 conformance pass is invalidated.
Forward-compatibility clauses: a host on suite ≤1.x that does not implement this RFC stays conformant by advertising fanOutSupported: false and rejecting parallel at registration — the exact behavior it already has. A workflow authored with fanOutPolicy: 'parallel' is portable only to hosts advertising fanOutSupported: true; authors detect portability via the capability, not by trial.
Conformance
Existing scenarios touching the area:
conformance/src/scenarios/subworkflow.test.ts— child-run dispatch baseline (core.subWorkflowmachinery the parallel path reuses).conformance/src/scenarios/dispatchLoop.test.ts— orchestrator → dispatch loop.dispatch-next-worker-sequential.test.ts,dispatch-next-worker-reject.test.ts(RFC 0007) — the two existing fan-out policies; these MUST remain green unchanged (proves the default did not move).
New scenarios required for Accepted (all gated on capabilities.dispatch.fanOutSupported: true):
dispatch-fanout-parallel-wait-all.test.ts— three children,wait-all+collect; all threenode.dispatchedevents present,core.dispatch.fanOut.childCount === 3, join emits after the last terminal,joinOutcome: 'satisfied', outputchildrenlength 3.dispatch-fanout-parallel-partial.test.ts— one child fails undercollect;joinOutcome: 'partial', node SUCCEEDS, failed child surfaces inchildren[]with its error, parent run does NOT fail.dispatch-fanout-parallel-fail-fast.test.ts— one child fails underfail-fast; remaining children cancelled (cascade events present),joinOutcome: 'failed', run transitions tofailed.dispatch-fanout-parallel-quorum.test.ts—quorum: 2of 3; join satisfied at the second completion, third child cancelled,joinOutcome: 'satisfied'.dispatch-fanout-quorum-missing-field.test.ts—mode: 'quorum'withoutquorum→validation_errorat registration.dispatch-fanout-joinpolicy-without-parallel.test.ts—joinPolicypresent withfanOutPolicy: 'sequential'→validation_error.dispatch-fanout-unsupported-capability.test.ts—fanOutPolicy: 'parallel'against a host advertisingfanOutSupported: false→validation_error(this scenario runs un-gated, asserting graceful rejection on non-supporting hosts).dispatch-fanout-replay-merge-order.test.ts— two children map to the same parent variable; replay re-appliesoutputMappingin the loggedmergeOrder, reproducing the original parent-variable value (RFC 0041 replay determinism).dispatch-fanout-max-concurrency.test.ts—nextWorkerIds.length 4,maxConcurrency: 2; never more than 2 children inrunningsimultaneously (asserted via interleavednode.dispatched/ child-terminal events), all 4 still complete.
The replay-merge-order and max-concurrency scenarios are suite-version requirements (suite 1.x asserts stricter determinism/observability than the prose strictly compels) per COMPATIBILITY.md §2.3 and are tagged as such.
Alternatives considered
1. Do nothing — keep sequential-only. Rejected: it forces every fan-out author into either a monolithic mega-child (losing per-child run lineage, independent approval gates, and replay isolation) or a vendor.* parallel dispatch model that breaks cross-host portability — the precise failure RFC 0007 §Motivation exists to prevent. The campaign workflow's 5× serialization (≈25 min vs ≈5 min) is a concrete cost, and a single channel's HITL stall blocking all others is a correctness-of-experience problem, not just throughput. 2. A separate core.parallelDispatch typeId instead of extending fanOutPolicy. Rejected: RFC 0007 already made fanOutPolicy the home for fan-out behavior and named parallel as its next value (§K3). A second node type bifurcates the dispatch surface, duplicates the input/output-mapping config, and forces authors to choose a node type by concurrency — a per-config decision, not a per-node-type one. 3. joinPolicy as a flat enum (e.g. joinPolicy: 'wait-all') rather than an object. Rejected: §K3 itself flags that the hard part is error aggregation — which is orthogonal to completion (mode). A flat enum would have to cross-product {wait-all, quorum, first, race} × {fail-fast, absorb, collect} into a dozen opaque tokens. The two-field object names the two independent axes and keeps each extensible. 4. Implicit unbounded parallelism (no maxConcurrency). Rejected: a 200-child fan-out would let a single run exhaust a host's worker pool. Bounded concurrency with an advertised maxFanOut ceiling lets authors plan and hosts self-protect, and degrades gracefully to waves rather than rejecting the workflow. 5. Resolve §K1 (same-run sibling nodes) here too, dispatching children as DAG peers rather than child runs for lower boot cost. Rejected: re-entrancy and cross-sibling state-sharing are non-trivial (RFC 0007 §K1 defers to ~v1.3) and independent of the join-policy question this RFC answers. Keeping child-run semantics means the parallel path reuses the already-conformant core.subWorkflow lineage, attestation (RFC 0063), and ancestry (RFC 0040) machinery unchanged.
Resolved questions (resolved at Active)
Because Active locks the wire shape, all five opens are resolved in-RFC at the Draft → Active waiver. Each resolution adopts the floor the proposal already named and leaves any richer behavior to a future additive field/event, so none narrows or pre-commits the locked joinPolicy / event surface:
1. Default onChildFailure — RESOLVED: 'collect'. The default does NOT short-circuit on an individual child failure; the parent author decides what a partial failure means via downstream edge conditions. This is the safest default for the common case (independent fan-out children, e.g. the campaign five-channel workload). It deliberately diverges from the single-child core.subWorkflow.onChildFailure: 'fail-parent' default — node-packs.md MUST document the divergence prominently — because the N-child case is qualitatively different (one channel failing should not silently abort four healthy siblings). Authors who want the old behavior set onChildFailure: 'fail-fast' explicitly. 2. race vs first — RESOLVED: keep both enum values. 'first' (first completed) and 'race' (first terminal of any kind, success or failure) stay distinct. Collapsing 'race' into mode: 'first' + onChildFailure: 'fail-fast' would overload the two orthogonal axes (mode = completion condition, onChildFailure = error aggregation) the RFC deliberately separates; the split is clearer for authors and keeps each axis independently extensible. The cost is one extra enum token, which is cheap and additive. 3. Partial-output streaming — RESOLVED: coarse fanOut/join markers only in v1. The parallel path emits core.dispatch.fanOut / core.dispatch.join (with mergeOrder) but does NOT stream per-child output.chunk events. That coarse pair is sufficient for an SSE consumer to drive a live progress UI (wave-begin + per-child node.dispatched + join). Per-child output streaming (§K2) is a future-additive event surface that does not change this RFC's locked event $defs. 4. Cross-host parallel children — RESOLVED: mergeOrder is the parent host's observed terminal order. When concurrent children run on different hosts (RFC 0040 ancestry), the parent host records mergeOrder from its own clock at terminal-fold time — a single-clock, monotonic order, never the children's own timestamps. This makes the merge tiebreak replay-deterministic regardless of cross-host clock skew or partial-failure visibility lag; §G and the implementation notes already mandate this, and it is now a normative clause rather than an open question. 5. Interaction with iterationCap — RESOLVED: one dispatch-iteration per child. Each child dispatched in a parallel wave counts as one dispatch-iteration against iterationCap (so a maxConcurrency: 5 fan-out consumes 5), consistent with sequential fan-out. The fan-out node does NOT count once for the whole wave. This keeps the cap a true bound on total child dispatches across both policies.
Implementation notes (non-normative)
- The reference TypeScript host already serializes children in a transient channel for
'sequential'(RFC 0007 §implementation-notes). The parallel path swaps the serialawaitfor a boundedPromise.allSettledover a concurrency-limited queue, then folds terminals perjoinPolicy. Thecore.subWorkflowchild-construction, lineage (parentRunId/parentNodeId), attestation (RFC 0063), and ancestry (RFC 0040) machinery are reused unchanged — only the scheduling and the join/merge step are new. mergeOrderis the host's observed child-terminal order; persist it on thecore.dispatch.joinevent at first execution and read it on replay. Do NOT recompute it from child timestamps (clock skew across cross-host children, §UQ4).- Sequencing: this RFC is the wire dependency for openwop-app's Campaign Studio port (
docs/campaign-studio-prd.md, the channel fan-out phase). The host port's parent orchestration can be authored againstfanOutPolicy: 'parallel'the moment the host advertisesfanOutSupported: true; until then it falls back to'sequential'(correct, just slower) with no workflow rewrite — only a config flip. The host should ship sequential first, then flip the flag when the parallel scheduler + the nine conformance scenarios are green. - Cross-cut: add a
CC-Nentry to the impl plan only if the reference host's dispatch executor is mid-refactor; otherwise this is an additive executor change that can merge independently.
Acceptance criteria
- [x] Spec text merged (
node-packs.mdcore.dispatchfan-out section +capabilities.mdcapabilities.dispatch.*;onChildFailureModesdescriptor added via openwop#789). - [x]
dispatch-config.schema.jsonupdated (fanOutPolicyenum +joinPolicy+maxConcurrency);run-event-payloads.schema.jsonaddsdispatchFanOut/dispatchJoin$defs;api/asyncapi.yamlreferences them. - [x] At least one conformance scenario per §B mode + the partial/fail-fast/replay-merge cases land in
@openwop/openwop-conformance(dispatch-fanout-parallel.test.ts, suite 1.45.0). - [x] CHANGELOG entry citing RFC 0118 and closing RFC 0007 §K3.
- [x] Reference host advertises
fanOutSupported: true(openwop-app prod + MyndHyve tier-2 sibling, both steward-curl-verified 2026-06-28 — seeINTEROP-MATRIX.md§"Parallel sub-workflow fan-out and join"). Behavioral leg: single-witness host-self-reported (MyndHyvedispatch-fanout-parallel10/10 non-vacuous); openwop-app's seam is dark in prod by deliberate posture. Steward-run authenticated cross-check deferred (keys not exchanged).
References
- RFC 0007 (Dispatch) — §D fan-out semantics, §G capability shape, §K3 the deferral this RFC closes.
- RFC 0006 (Orchestrator) — the
OrchestratorDecision(next-worker) this dispatches. - RFC 0022 (Dispatch input/output mapping) — per-child
inputMapping/outputMappingreused under fan-out. - RFC 0063 (Sub-run output attestation & merge gating) — per-child attestation composes with the parallel merge.
- RFC 0040 (Multi-agent cross-host causation) — child-run ancestry /
parentRunIdlineage the parallel children inherit. - RFC 0041 (Replay) — the determinism guarantee §G satisfies via
mergeOrder. interrupt-profiles.md§Parent-child cascade — the cancellation cascadequorum/first/race/fail-fastinvoke for in-flight children.- Prior art: BPMN parallel gateway (fork/join) + completion conditions; Temporal child-workflow
ParallelExecution+Promise.allSettledjoin; LangGraphSendAPI fan-out; Step FunctionsMap/Parallelstates withToleratedFailurePercentage(thejoinPolicy.onChildFailureanalogue). - Implementer:
openwop-app/docs/campaign-studio-prd.md(Campaign Studio port) — the motivating five-channel parallel fan-out.