Summary
At v1, a host said which parallel fan-outs it could run: capabilities.dispatch.fanOutPolicies, joinModes, onChildFailureModes, maxFanOut and perItemInput (RFCs 0118, 0126). The closed v2 agents record kept only dispatch, a boolean, so a v2 host cannot advertise parallel fan-out at all, and the rule that refused a join mode the host does not implement lost its gate. This RFC gives the descriptor a v2 seat, agents.fanOut, and names where its refusal is observed: v2 has no registration endpoint, so the observable is createRun. It also closes the converse of the identity.md §3 rule suite 2.46.3 began to witness: a host MUST NOT advertise auth.subjectLinkKey without both the saml and scim lanes.
Motivation
openwop-app's v2 discovery review (#4676, ADR 0858) found that dispatch-config.schema.json still describes fanOutPolicy: 'parallel', joinPolicy and maxConcurrency, and orchestrator-decision.schema.json still describes nextWorkerInputs, but nothing in a v2 capability record says whether a host runs them. Suite 2.46.3 corrected the descriptions to say what follows today: with no seat, a v2 host refuses parallel and nextWorkerInputs. That is honest, but it means no v2 host can offer the RFC 0118 surface, however well it implements it.
The same review (ADR 0812) found that identity.md §3 says both linking lanes imply the link contract, and that the key names its class, but says nothing about a key advertised with one lane. A key names a link contract; the link contract is the saml decision path consulting a link formed from scim. A key without both lanes promises a link the host cannot form. Twenty committed openwop-app bundles advertise opaque-idp without saml. Its live advertisement no longer does (lanes api-key, session, anonymous, oidc, no key; read 2026-10-12).
Proposal
§A. agents.fanOut
One optional facet on agents, in spec/v2/facets/agents.schema.json. Its description is the rule; this section summarizes it and does not restate it.
"fanOut": {
"type": "object", "additionalProperties": false,
"required": ["joinModes", "onChildFailureModes"],
"properties": {
"joinModes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["wait-all", "quorum", "first", "race"] } },
"onChildFailureModes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["collect", "fail-fast", "absorb"] } },
"maxFanOut": { "type": "integer", "minimum": 1 },
"perItemInput": { "type": "boolean", "default": false }
}
}
- A.1 The facet's presence is the claim that the host runs
fanOutPolicy: 'parallel'; there is nofanOutSupportedand nofanOutPolicies.sequentialandrejectneed no advertisement. A host MUST NOT advertisefanOutwithoutagents.dispatch: true. - A.2 A host MUST refuse a workflow whose
core.dispatchnode pins what the facet does not admit (parallelwith the facet absent, ajoinPolicy.modeoutsidejoinModes, ajoinPolicy.onChildFailureoutsideonChildFailureModes) withvalidation_error, anddetails.requiredCapabilitynames the seat it lacks. It MUST refuse when it loads the workflow, as the 2.46.3 descriptions already say. Where such a workflow is nonetheless readable throughgetWorkflow,createRunnaming it MUST return400 validation_errorwith thatdetails.requiredCapabilityand create no run. - A.3
maxFanOutkeeps its v1 meaning: effective concurrency ismin(config.maxConcurrency ?? ∞, maxFanOut ?? ∞), dispatched in waves, never dropping a child. - A.4 Without
perItemInput: true, a non-emptynextWorkerInputsfails the dispatch node withvalidation_errorand dispatches no child (the 2.46.3 rule, now gated on a seat that exists).
Positive: { "dispatch": true, "fanOut": { "joinModes": ["wait-all"], "onChildFailureModes": ["collect"], "maxFanOut": 8 } }. Negative: { "fanOut": { "joinModes": [] , "onChildFailureModes": ["collect"] } } fails the schema (minItems), and { "dispatch": false, "fanOut": { … } } fails A.1.
§B. auth.subjectLinkKey needs both lanes
identity.md §3 now reads: "Advertising both saml and scim lanes implies the link contract: the host MUST then advertise auth.subjectLinkKey (opaque-idp | configured-immutable), and MUST NOT otherwise." The edit is word-neutral against the core budget (37,999 / 38,000).
Compatibility
additive. §A adds an optional property to a closed object, which COMPATIBILITY.md §2.4 allows within a major. A host that does not advertise fanOut is in the position 2.46.3 already described. A.2's createRun clause gives the existing load-time MUST an observable fallback; it relaxes nothing.
§B is a new MUST NOT. It is not carried by the schema: forbidding a property unless two array members are present is a conditional the schema cannot add within a major (COMPATIBILITY.md §2.4), so the suite carries it, as RFC 0210 §C did. A committed bundle stays a valid measurement at the suite it was cut on (§2.3); the twenty openwop-app bundles are reported, not re-judged. No host's live advertisement breaks the rule today.
Conformance
v2-agents-fan-out(new): A.1 from discovery (agentsFanOutDispatchLeg), and A.2 against the seeded fixtureconformance-dispatch-fanout-race(aparalleldispatch with aracejoin andcollect). The leg runs on a host advertisingagents.dispatch: truethat does not admit that dispatch and lists the fixture in discoveryfixtures; a fixturegetWorkflowanswers404for isinapplicable.v2-auth-subject-link-key: a second leg for §B (authSubjectLinkKeyLanesLeg).- Self-tests in
lib/family-advert-witness.test.ts(wave 5).
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
§A.1 no fanOut without dispatch: true | the discovery record | the host's own advertisement; the suite reads it (openwop.requirement.0243.fan-out-needs-dispatch) | witnessable (claims-check) |
| §A.2 refusal at load | nothing: v2 has no registration endpoint, and seeding is implementation-specific | the operator, by seeding | unwitnessable from outside. A fixture refused at load is indistinguishable from one never seeded, so the leg records inapplicable, not pass. |
§A.2 refusal at createRun | 400 validation_error with details.requiredCapability, and no run | the suite, given the seeded fixture (openwop.requirement.0243.unadmitted-fan-out-refused) | witnessable-gated (fixture) |
§A.3 maxFanOut waves | the children's node.dispatched → terminal windows over a live fan-out never overlap more than the ceiling, and none is dropped | the suite, on a host advertising fanOut that lists conformance-dispatch-fanout-waves (openwop.requirement.0243.max-fan-out-waves) | witnessable-gated (fixture) |
§A.4 nextWorkerInputs without perItemInput | the dispatch node fails validation_error, no child run; with it, each child's run.inputs carries its item | the suite, through fixtures whose supervisor emits a mockDispatchPlan (openwop.requirement.0243.per-item-input-gated) | witnessable-gated (fixture) |
| §B no key without both lanes | the discovery record | the host's own advertisement (openwop.requirement.0243.subject-link-key-needs-both-lanes) | witnessable (claims-check) |
Alternatives considered
- Do nothing. The 2.46.3 descriptions are honest, but they leave RFC 0118 unofferable at v2, and an implementer reading
dispatch-configfinds a surface no host may advertise. - Restore v1's
dispatch.fanOutSupported+fanOutPoliciesverbatim. Asupportedboolean is the shape v2 retired (RFC 0192 §B), andfanOutPoliciesrestates what presence already says. - Put §B in the schema (
if not both lanes then not required subjectLinkKey). A conditional the schema did not have is a major change (§2.4); it waits for 3.0, with the other conditionals TODO.md books there. - Observe A.2 through a new seam. A seam would make the load-time refusal causable, but a seam-only witness does not close a requirement on a host without seams (
conformance.md§Witness class), andcreateRunalready gives an unaided observable.
Unresolved questions
None outstanding at Active. G1 and G2 in the gap register record what Accepted needs.
Implementation notes (non-normative)
A host that implements RFC 0118 at v2 advertises fanOut with the modes it runs. A host that does not need do nothing new unless it seeds conformance-dispatch-fanout-race: then its loader refuses the fixture, or its createRun does. The v1→v2 mapping (capabilities.dispatch. → agents.fanOut.) cannot be a migration row, because v1 rows live in the frozen spec/v1/migrations.json.
Acceptance criteria
- [x] Spec text merged (the facet description,
identity.md§3, theexecution.mdfacet table). - [x] Schema updated (
agents.schema.json, generatedcapabilities.schema.json, the two description edits). - [x] Conformance scenarios covering the new surface (
v2-agents-fan-out,v2-auth-subject-link-key). - [x] CHANGELOG entry under suite 2.46.4.
- [x] A certified major-2 bundle records
openwop.requirement.0243.unadmitted-fan-out-refusedexecuted-passfrom a host advertisingagents.dispatch: truethat seeds the fixture (G1; the v2 reference host advertises nodispatch, so this is a production host), and0243.subject-link-key-needs-both-lanesexecuted-passon a certified bundle from a host advertisingsubjectLinkKey(the v2 reference host; openwop-app advertises no key, so the row isinapplicablethere). (2026-10-13: openwop-app's certified 2.46.4 cut, build8d2adcbe8, records both §A idsexecuted-pass; the v2 reference host's certified 2.46.4 public cut, build89a8931f, records the §B idexecuted-pass.)
References
- RFC 0118 (parallel fan-out and join), RFC 0126 (per-item input), RFC 0022 (
dispatchMappingrefusal shape), RFC 0164 §A.3, RFC 0192 §B, RFC 0210 §C. spec/v2/facets/agents.schema.json§fanOut,spec/v2/core/execution.md§agents,spec/v2/core/identity.md§3.- openwop-app #4676, ADR 0858, ADR 0812.