| Field | Value |
|---|---|
| RFC | 0031 |
| Title | Discriminated-union pattern for envelope variant payloads (normative); NodeModule.requiredModelCapabilities + NodeModule.fallbackModel; model.capability.substituted + model.capability.insufficient events |
| Status | Accepted |
| Author(s) | OpenWOP Working Group |
| Created | 2026-05-20 |
| Updated | 2026-05-21 (Active → Accepted — see Status history below). |
| Affects | spec/v1/ai-envelope.md (adds §"Variant payload discrimination (normative)") · spec/v1/host-capabilities.md (adds §"Model-capability declarations") · spec/v1/node-packs.md (notes NodeModule.requiredModelCapabilities + NodeModule.fallbackModel) · schemas/node-pack-manifest.schema.json (adds requiredModelCapabilities + fallbackModel to the NodeModule shape) · schemas/capabilities.schema.json (adds optional modelCapabilities block) · schemas/run-event.schema.json (adds model.capability.substituted + model.capability.insufficient enum entries) · schemas/run-event-payloads.schema.json (adds modelCapabilitySubstituted + modelCapabilityInsufficient $defs) · SECURITY/invariants.yaml (adds model-capability-substituted-no-credential-disclosure) · 4 new conformance scenarios · CHANGELOG |
| Compatibility | additive |
| Supersedes | — |
Summary
Two complementary additive changes that together close the "envelope dispatch routing is opaque" gap. First, codifies the de-facto discriminated-union pattern (anyOf + single-string-enum discriminator per branch) as normative for any envelope payload schema that accepts variant shapes — preventing future authors from accidentally using oneOf (cross-vendor incompatible per RFC 0030 §B). Second, adds two optional fields to the NodeModule shape — requiredModelCapabilities: string[] and fallbackModel: { provider, model } — so envelope-emitting NodeModules can declare which model capabilities they need (e.g., structured-output, discriminator-enum, long-context, reasoning, function-calling) and which model the host MAY substitute when the active model can't satisfy them. Hosts emit two new RunEventType entries (model.capability.substituted, model.capability.insufficient) so conformance suites and observability tools can assert capability-gated dispatch behavior. The two model.capability.* events are cross-kind operational events about model dispatch — they do NOT extend the per-envelope-kind event surface that ai-envelope.md line 448 forbids; see the same-scope analysis in RFC 0032 §A.
Motivation
2.1 Variant envelopes silently break on Gemini and Anthropic when authored with oneOf
The de-facto pattern in existing universal-kind and vendor-namespaced envelope payloads is anyOf with a single-string-enum discriminator (kind: "design" | "planning" | "action", etc.). But the spec's §"Schema discipline" doesn't normate this — a future canvas-type author writing a new variant envelope can pick oneOf and produce a schema that:
- OpenAI strict mode rejects with a 400.
- Anthropic strict tool use rejects with a 400.
- Gemini
responseSchema_silently drops_ theoneOfkeyword (perdocs.cloud.google.com/vertex-ai/...— "Vertex AI can still handle your request but ignores the field"), producing a schema looser than the author intended.
The fix is structural: spec text that says "variant envelopes SHALL use anyOf with a single-string-enum discriminator per branch." This is a forward-looking normative requirement; existing universal-kind schemas already comply because they don't have variants. The vendor-namespaced kinds the reference MyndHyve host ships (vendor.myndhyve.prd.create, vendor.myndhyve.theme.create, vendor.myndhyve.tasks.create) already use this pattern; codifying it prevents drift.
2.2 Envelope-emitting nodes have implicit model dependencies the protocol can't see
A NodeModule like core.ai.callPrompt (or any vendor-namespaced envelope-emitting node) has implicit dependencies on model capabilities:
- It needs
structured-outputsupport (Anthropic strict tool use, OpenAI strict mode, GeminiresponseSchema) to emit a payload matching the envelope's JSON Schema. - It needs
discriminator-enumsupport (the single-stringenum: ["literal"]pattern per §A) if the envelope has variants. - It might need
long-context(≥ 200k tokens) for envelope types that consume large prior context. - It might need
reasoning(Anthropic extended thinking, GeminithinkingBudget, OpenAI o-series) for envelope types where chain-of-thought materially improves output quality (per RFC 0030 §A). - It might need
function-callingfor tool-using envelope flows.
Today, hosts each invent their own dispatch-routing for this. Pack-distributed NodeModules (RFC 0003 node packs, RFC 0013 workflow-chain packs) can't declare requirements portably — a pack authored against an anthropic/claude-opus-4-7 deployment may silently misbehave when run against a gemini/flash deployment that lacks the same structured-output guarantees. The protocol's existing NodeModule.requires: string[] (per schemas/capabilities.schema.json lines 211, runtime.* namespace) covers host capabilities (e.g., secrets.byok, chat.sendPrompt, canvas.write) — it does not cover model capabilities (which model the host's dispatcher routes to).
This RFC adds a parallel requiredModelCapabilities field and a fallbackModel substitution mechanism so envelope-emitting nodes can declare their model dependencies portably. Hosts honor the declaration: substitute to the fallback when the active model is unsuitable, or refuse to dispatch with an explicit capability_not_provided (per capabilities.md §"Unsupported capability — refusal contract").
2.3 Substitution and insufficient-capability dispatches need protocol-visible telemetry
When a host substitutes to a fallback model or refuses to dispatch, downstream observability tools and conformance suites need a stable event surface to assert behavior. The existing capability_not_provided error code surfaces _host_-capability refusals on RunSnapshot.error; _model_-capability events need their own observable signal. Two new RunEvent types (one for substitution, one for refusal) parallel the precedent set by RFC 0026 (provider.usage) and RFC 0027 (prompt.composed).
Proposal
§A — Discriminated-union pattern for variant envelopes (normative)
Add a new §"Variant payload discrimination (normative)" to spec/v1/ai-envelope.md between §"Schema discipline" and §"Envelope Contract":
When an envelope payload schema accepts variant shapes (a sum type), the schema SHALL express the variants as an
anyOfcomposition where every branch:1. Includes a discriminator property of type
stringwithenumcontaining exactly one value (the discriminator literal for that branch).2. Includes the discriminator property in
required.3. Independently satisfies the Tier-1 Compatibility Subset (per
spec/v1/structured-output-subset.md/ RFC 0030 §B).Hosts MUST be able to identify the active variant by single-field equality check on the discriminator. Hosts MUST NOT depend on field-presence inference or value-shape heuristics for variant discrimination.
Discriminator field names are conventionally
kind,variant, ortype. Specs MAY use other names but MUST document the chosen name in each envelope-type definition.Schema authors MUST NOT use
oneOffor variant payloads — it is unsupported across all three Tier-1 strict-output vendors (OpenAI strict, Anthropic strict, GeminiresponseSchema). Schema authors MUST NOT use field-presence-based discrimination — Gemini silently drops the discriminating keyword, and hosts targeting it would route to the wrong handler.
Example (illustrative, for a hypothetical vendor.acme.tasks.create envelope):
{
"type": "object",
"additionalProperties": false,
"required": ["steps"],
"properties": {
"reasoning": { "type": ["string", "null"] },
"steps": {
"type": "array",
"items": {
"anyOf": [
{ "$ref": "#/$defs/DesignTask" },
{ "$ref": "#/$defs/PlanningTask" },
{ "$ref": "#/$defs/ActionTask" }
]
}
}
},
"$defs": {
"DesignTask": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "title"],
"properties": {
"kind": { "type": "string", "enum": ["design"] },
"title": { "type": "string" }
}
},
"PlanningTask": { "...": "..." },
"ActionTask": { "...": "..." }
}
}
Backward compatibility for existing envelopes
The four universal-kind payload schemas (clarification.request, schema.request, schema.response, error) do not have variants today and remain conformant. Vendor-namespaced kinds that already use anyOf + single-string-enum discriminators (the MyndHyve vendor.myndhyve.* family) remain conformant. Vendor-namespaced kinds (if any exist) that use oneOf or field-presence-based discrimination remain conformant for v1.x — this RFC normates the pattern forward-looking only. A future RFC MAY tighten the requirement to existing schemas.
§B — NodeModule.requiredModelCapabilities + NodeModule.fallbackModel
Add two optional fields to the NodeModule shape in schemas/node-pack-manifest.schema.json:
"properties": {
"typeId": { ... },
"schemaVersion": { ... },
"title": { ... },
"description": { ... },
"configSchema": { ... },
"inputSchema": { ... },
"outputSchema": { ... },
"requires": { ... },
+ "requiredModelCapabilities": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^([a-z][a-z0-9-]*|x-host-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*)$",
+ "description": "Capability identifier per RFC 0031 §C. Spec-defined identifiers are reserved (`structured-output`, `discriminator-enum`, `long-context`, `reasoning`, `function-calling`); host-private extensions MUST prefix with `x-host-<host>-`."
+ },
+ "uniqueItems": true,
+ "maxItems": 32,
+ "description": "Model capabilities this NodeModule requires the active model to advertise. The host MUST check the active model's advertised capabilities against this list before dispatching. Distinct from `requires`, which gates on host capabilities (per `capabilities.runtime`). Empty array (or absent field) means no model-capability requirements."
+ },
+ "fallbackModel": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["provider", "model"],
+ "properties": {
+ "provider": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "Provider id per `capabilities.aiProviders.supported` (e.g., `anthropic`, `openai`, `gemini`)." },
+ "model": { "type": "string", "minLength": 1, "description": "Provider-stamped model id (e.g., `claude-opus-4-7`, `gpt-5`, `gemini-2.5-pro`)." }
+ },
+ "description": "Substitute model coordinates the host MAY use if the active model lacks the declared `requiredModelCapabilities`. When the host substitutes, it MUST emit `model.capability.substituted` (§D). Absent means no substitution permitted; the host MUST refuse to dispatch with `capability_not_provided` when capabilities are unmet."
+ },
"...": "..."
}
Dispatch flow (normative)
When dispatching a NodeModule that declares requiredModelCapabilities, a host SHALL:
1. Check the active model's advertised capabilities against requiredModelCapabilities. 2. If all required capabilities are met: dispatch normally. 3. If unmet AND fallbackModel is declared AND the host can authenticate to the fallback provider (i.e., the fallback's provider is in capabilities.aiProviders.supported AND a credential is resolvable): substitute and emit model.capability.substituted (§D); dispatch with the fallback model. 4. If unmet AND (no fallbackModel declared, OR the host cannot authenticate to the fallback): emit model.capability.insufficient (§D) and refuse to dispatch the node with error.code = "capability_not_provided" per capabilities.md §"Unsupported capability — refusal contract". The run transitions to failed unless the run's retry policy intervenes.
The order MUST be: capability check → optional substitution → emit telemetry → dispatch or refuse. Hosts MUST NOT substitute silently (no event emission); hosts MUST NOT dispatch with an unsuitable model and hope for the best (the model's runtime failure is a worse signal than refusing up-front).
§C — Capability identifier registry
Defined initial capability identifiers (extensible by host registration):
| Identifier | Meaning |
|---|---|
structured-output | Vendor strict-mode JSON Schema support (Anthropic strict tool use strict: true, OpenAI strict mode response_format.json_schema.strict: true, Gemini responseSchema on generateContent). Per spec/v1/structured-output-subset.md (RFC 0030 §B). |
discriminator-enum | Single-string enum: ["literal"] discriminator support in anyOf branches per §A. All three Tier-1 vendors support this when their respective strict modes are engaged. |
long-context | Context window ≥ 200k tokens. |
reasoning | Native reasoning / thinking tokens (Anthropic extended thinking, Gemini thinkingBudget, OpenAI o-series reasoning). Sibling concept to RFC 0030's envelope-payload reasoning field — reasoning here means _model-native_ thinking-tokens, NOT envelope-payload chain-of-thought. |
function-calling | Multi-turn function-calling / tool-use loop support. |
Reservation policy. Identifiers in the table above are spec-reserved. Hosts MAY add private extensions via the x-host-<host>-<key> prefix per host-extensions.md §"Canonical-prefix table." A future RFC MAY add new spec-reserved identifiers as new model capabilities materialize.
Host advertisement. A host's advertised model-capability set lives at capabilities.modelCapabilities.advertised per §E. The host's dispatcher is the authoritative source of truth for which active model satisfies which identifier; the protocol does not normate how the host derives the mapping (vendor-API probes, static lookup tables, configuration files — all valid).
Truthful advertisement (normative; amendment 2026-05-21 from RFC adoption feedback). capabilities.modelCapabilities.advertised[] MUST reflect only the capability identifiers the host actually gates on at dispatch (i.e., identifiers some NodeModule.requiredModelCapabilities[] in the host's registered pack set references AND that the host's dispatcher evaluates). Pasting the full spec-reserved set as boilerplate is dishonest per capabilities.md §"Truthful advertisement" and is non-conformant. Hosts whose pack registry only references a subset of the spec-reserved identifiers MUST advertise only that subset; adding identifiers later (when new NodeModules declare them) is an additive bump.
§D — Two new RunEventType entries
schemas/run-event.schema.json RunEventType enum gains two new entries:
"agent.reasoned",
"agent.reasoning.delta",
+ "model.capability.substituted",
+ "model.capability.insufficient",
"prompt.composed",
"provider.usage",
schemas/run-event-payloads.schema.json gains two new $defs:
"modelCapabilitySubstituted": {
"type": "object",
"additionalProperties": false,
"required": ["nodeId", "originalProvider", "originalModel", "fallbackProvider", "fallbackModel", "missingCapabilities"],
"description": "Emitted when a host substitutes the active model with a NodeModule's declared `fallbackModel` because the active model lacks one or more of the `requiredModelCapabilities`. MUST event per RFC 0031 §B step 3.",
"properties": {
"nodeId": { "type": "string", "description": "The node whose dispatch triggered the substitution." },
"originalProvider": { "type": "string", "description": "Provider id of the active model the host was about to use." },
"originalModel": { "type": "string", "description": "Model id of the active model." },
"fallbackProvider": { "type": "string", "description": "Provider id of the substitute model from `NodeModule.fallbackModel.provider`." },
"fallbackModel": { "type": "string", "description": "Model id of the substitute model from `NodeModule.fallbackModel.model`." },
"missingCapabilities": {
"type": "array",
"items": { "type": "string" },
"description": "Subset of `NodeModule.requiredModelCapabilities` that the active model did not satisfy."
}
}
},
"modelCapabilityInsufficient": {
"type": "object",
"additionalProperties": false,
"required": ["nodeId", "provider", "model", "missingCapabilities"],
"description": "Emitted when a host refuses to dispatch a NodeModule because the active model lacks declared `requiredModelCapabilities` AND no viable fallback is available (no `fallbackModel` declared, OR the host cannot authenticate to the fallback provider). MUST event per RFC 0031 §B step 4. Pairs with `RunSnapshot.error.code = \"capability_not_provided\"`.",
"properties": {
"nodeId": { "type": "string", "description": "The node whose dispatch was refused." },
"provider": { "type": "string", "description": "Provider id of the active model the host attempted to use." },
"model": { "type": "string", "description": "Model id of the active model." },
"missingCapabilities": {
"type": "array",
"items": { "type": "string" },
"description": "Subset of `NodeModule.requiredModelCapabilities` that the active model did not satisfy."
},
"fallbackAttempted": {
"type": "boolean",
"default": false,
"description": "True if the host attempted to authenticate to a declared fallback and failed. False if no `fallbackModel` was declared (or the fallback provider was outside `capabilities.aiProviders.supported`)."
}
}
}
And a discriminator entry in _typeIndex:
+ "model.capability.substituted": { "$ref": "#/$defs/modelCapabilitySubstituted" },
+ "model.capability.insufficient": { "$ref": "#/$defs/modelCapabilityInsufficient" },
Scope clarification re: ai-envelope.md line 448. The line-448 MUST NOT prohibits hosts from extending RunEventType to add _envelope-kind-specific_ events (one event per envelope kind, e.g., a clarification.requested mirroring clarification.request payload). The two events introduced here — model.capability.substituted and model.capability.insufficient — are cross-kind operational events: they fire during model dispatch, before any envelope is emitted, and apply uniformly regardless of which envelope kind the node ends up emitting. Same posture as RFC 0026's provider.usage and RFC 0027's prompt.composed; both extended RunEventType without conflict. The formal carve-out for cross-kind operational envelope-track events is normated by RFC 0032 §A; RFC 0031 cites that carve-out by reference but does not depend on it (these events fire before envelope emission, not as part of envelope handling).
§E — Capability advertisement
schemas/capabilities.schema.json gains one new optional top-level block:
+ "modelCapabilities": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["supported"],
+ "properties": {
+ "supported": {
+ "type": "boolean",
+ "description": "Host honors `NodeModule.requiredModelCapabilities` and emits `model.capability.{substituted,insufficient}` per RFC 0031. When false or absent, the fields are treated as opaque metadata and dispatch proceeds without capability checks."
+ },
+ "advertised": {
+ "type": "array",
+ "items": { "type": "string", "pattern": "^([a-z][a-z0-9-]*|x-host-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*)$" },
+ "uniqueItems": true,
+ "description": "Capability identifiers the host's active model advertises. Clients MAY introspect this at install time to determine whether their NodeModules' `requiredModelCapabilities` are satisfiable without fallback."
+ },
+ "substitutionSupported": {
+ "type": "boolean",
+ "description": "Host honors `NodeModule.fallbackModel` substitution per RFC 0031 §B step 3. **Scope (normative; clarified by amendment 2026-05-21):** substitution is per-NodeModule, NOT host-wide. When `true`, the host evaluates `requiredModelCapabilities` + `fallbackModel` per dispatch of an envelope-emitting NodeModule (the unit at which the NodeModule schema declares the fields). Hosts that do not implement a per-call provider-swap facility (i.e., they cannot rewrite `ctx.callAI({provider: ...})` mid-dispatch to use the fallback model) MUST advertise `false` — emitting `model.capability.substituted` without actually substituting is wire-contract-dishonest. When false or absent, hosts MUST refuse to dispatch (step 4) on any unmet capability even when `NodeModule.fallbackModel` is declared."
+ }
+ },
+ "description": "Advertisement that the host implements RFC 0031 model-capability gating + substitution. Absent block = no capability gating; NodeModules' `requiredModelCapabilities` are ignored."
+ }
§F — Security carry-forward
model.capability.substituted reveals which alternate provider/model the host has credentials for. Some hosts treat this as private (a workspace might not want to disclose its multi-vendor posture). A new SECURITY invariant lands:
- id: model-capability-substituted-no-credential-disclosure
tier: protocol
severity: medium
threat_model: SECURITY/threat-model-secret-leakage.md
tests:
- conformance/src/scenarios/model-capability-substituted.test.ts
note: |
RFC 0031 §F: hosts emitting `model.capability.substituted` MAY redact the
`fallbackProvider` and `fallbackModel` fields when workspace policy treats
multi-vendor posture as confidential. Redaction MUST be all-or-nothing for
the pair (both fields redacted as `"[REDACTED]"`) so consumers can detect
redaction without ambiguity. `nodeId`, `originalProvider`, `originalModel`,
and `missingCapabilities` MUST NOT be redacted — they carry no provider-
possession information beyond what the run-creation request already
disclosed via `RunOptions.configurable.ai.provider`.
The invariant is medium (not high) because: (a) the originalProvider is already public via RunOptions.configurable; (b) the fact that a host has _some_ fallback configured is conventionally public via capabilities.modelCapabilities.substitutionSupported: true. The hidden bit is which specific fallback fired for a given dispatch — workspace-internal info that some operators prefer to keep private.
Hosts that don't redact (the default) are conformant; hosts that redact MUST do so per the all-or-nothing rule above to keep the event shape predictable.
§G — Trust boundary (carry-forward)
Substitution and insufficient-capability events fire before envelope emission, so meta.contentTrust propagation per ai-envelope.md §"Trust boundary" doesn't apply directly. The two events carry no payload content from upstream sources; trust marking is unnecessary.
Compatibility
Additive per COMPATIBILITY.md §2.1. All claims:
- Existing required fields: unchanged. The two new
NodeModulefields are optional; existing node packs validate unchanged. - Existing optional fields: unchanged.
- Existing event types: unchanged (
model.capability.*are NEW enum entries; consumers MUST tolerate unknown event types per existing forward-compat). - Existing endpoints: unchanged.
- Existing MUST requirements: not relaxed. The §A normative rule applies to _new_ variant payload schemas; existing schemas with variants remain conformant for v1.x.
- Existing error codes: unchanged.
capability_not_providedis reused for the §B step 4 refusal.
Hosts that don't advertise capabilities.modelCapabilities see no behavioral change; requiredModelCapabilities is opaque metadata and dispatch proceeds without checks. NodeModules without requiredModelCapabilities see no behavioral change on any host.
Conformance
Four new scenarios under conformance/src/scenarios/. All gated as noted.
envelope-variant-discriminator-static.test.ts— always runs. For every kind in the host'ssupportedEnvelopesadvertisement, statically asserts: (1) if the payload schema contains ananyOf, every branch declares a single-string-enum discriminator per §A; (2) the payload schema MUST NOT containoneOfat any nesting depth. No LLM in the loop.
model-capability-substituted.test.ts— gated oncapabilities.modelCapabilities.supported: trueANDsubstitutionSupported: trueAND the test seamPOST /v1/host/sample/test/synthesize-model-capability-event. Drives a synthetic dispatch where the active model is configured to lackstructured-output; the NodeModule declaresfallbackModel. Asserts exactly onemodel.capability.substitutedevent fires withmissingCapabilities: ["structured-output"]and the expected fallback coordinates (or[REDACTED]when the host opts to redact per §F).
model-capability-insufficient.test.ts— gated oncapabilities.modelCapabilities.supported: trueAND the test seam. Drives a synthetic dispatch where the active model lacks a required capability AND the NodeModule declares nofallbackModel. Asserts: (1) exactly onemodel.capability.insufficientevent fires withfallbackAttempted: false; (2) the run terminates withRunSnapshot.error.code = "capability_not_provided"; (3) no envelope emission occurs.
node-module-required-capabilities-shape.test.ts— gated oncapabilities.modelCapabilities.supported: true. Statically validates that every NodeModule in the host's pack registry whosetypeIdis in thecore.ai.*namespace declaresrequiredModelCapabilities(host-policy assertion) — OR skips with a clear failure message if the host advertises capabilities but the spec doesn't yet have a host-policy gate. Treat as a SHOULD-tier scenario; conformance runs flag SHOULD-tier soft-fails separately from MUST-tier hard-fails.
The behaviorGate helper gains predicates: requireModelCapabilities() and requireModelCapabilitySubstitution().
Alternatives considered
1. **Reuse the existing NodeModule.requires: string[] field with a model.* namespace (e.g., model.structured-output).** Rejected — requires is keyed off capabilities.runtime.* per capabilities.md §"Runtime capabilities" and refuses on capability_not_provided without any substitution semantics. Overloading it with model capabilities would mean: (a) the refusal contract has to grow a substitution path on the runtime-capability surface, which the existing spec rules out; (b) the namespace becomes muddier — operators reading a manifest see requires: ["secrets.byok", "model.structured-output"] and have to know which strings gate on which capability surface. A dedicated requiredModelCapabilities field with its own dispatch flow is structurally cleaner.
2. Drop the substitution mechanism; refuse on every missing capability. Rejected — the substitution path is the load-bearing value of the RFC. Without it, pack authors have to gate their packs on capabilities.aiProviders.supported.includes("anthropic") and force operators to provision specific providers. The fallbackModel pattern lets a pack declare "I need structured-output; if your active model doesn't have it, here's a model that does." This is the cross-host portability story.
3. Make fallbackModel a per-run override instead of a per-NodeModule declaration. Rejected — per-NodeModule is correct because the capability requirement is intrinsic to the NodeModule's semantics. A node that emits a discriminated-union envelope needs discriminator-enum-capable model dispatch; that requirement doesn't change run-by-run. Per-run overrides would put the responsibility on every workflow author to know each node's intrinsic requirements — exactly the gap this RFC closes.
4. Use an existing event type (e.g., log.appended with code: "model.capability.substituted") instead of new RunEventType entries. Considered as an Option A fallback for the same MUST-NOT concern RFC 0032 addresses. Rejected here because: (a) the two events are cross-kind operational (not envelope-kind-specific) — they don't trigger the line-448 MUST NOT; (b) the precedent of RFC 0026 (provider.usage) and RFC 0027 (prompt.composed) already establishes that operational events extend RunEventType cleanly; (c) conformance assertions on dedicated event types are more precise than string-match on a log.appended.code.
5. Defer the discriminated-union codification to a future RFC; ship only the model-capability surface. Rejected — both halves are forward-looking schema/dispatch discipline that the protocol should ratify in the same release. Slicing them creates ceremony without spec-text benefit. The two halves do connect via the discriminator-enum capability identifier (§C), which references the §A pattern.
Unresolved questions
1. Capability identifier vocabulary growth. §C defines five initial identifiers. As new model capabilities materialize (e.g., vision-input, audio-output, code-execution), should they go through a full RFC process or via a lightweight registry-update mechanism? Recommendation: full RFC for now (single-steward bootstrap); revisit when the maintainer set grows.
2. Per-vendor capability mapping. A host's mapping from "active model = gpt-5" → "advertised capabilities = [structured-output, discriminator-enum, function-calling, reasoning]" is host-internal. Should the spec recommend a canonical mapping (e.g., "all OpenAI o-series advertise reasoning")? Recommendation: no — vendor model rosters change too fast; canonical mappings would be stale-by-design. Hosts MAY publish their mapping as documentation; the protocol surface is the advertised result.
3. Substitution recursion. A host substitutes from active model A to fallback model B because A lacks structured-output. What if B also lacks one of the _other_ required capabilities (e.g., reasoning)? Recommendation: NO recursive substitution — the host MUST evaluate the fallback model's full capability set before substituting; if the fallback also fails, the host MUST emit model.capability.insufficient with fallbackAttempted: true and refuse. Spec text in §B will need explicit clarification.
4. fallbackModel cost-attribution. When substitution fires, the provider.usage event (RFC 0026) attributes the cost to the fallback provider, not the original. Is this transparent enough for billing reconciliation? Recommendation: yes — provider.usage.provider already carries the actual provider that was charged. The model.capability.substituted event fires _before_ provider.usage, providing the causal chain.
5. Interaction with RFC 0027/0028/0029 prompt-resolution chain. A core.ai.callPrompt node with both requiredModelCapabilities (this RFC) and systemPromptRef (RFC 0027) needs both checked. Resolution order: capability check FIRST, then prompt resolution. Rationale: substitution may swap models with different prompt-tuning expectations; resolving prompt against the _original_ model when the dispatch ends up using the _fallback_ is incorrect. Document this ordering as non-normative implementation guidance.
Implementation notes (non-normative)
- Reference host
apps/workflow-engine/backend/typescript: extendexecutor/executor.tsdispatchNode(...)to (1) readNodeModule.requiredModelCapabilities, (2) cross-check against the active provider'scapabilities.modelCapabilities.advertised, (3) substitute toNodeModule.fallbackModelwhen unmet, (4) emit the appropriatemodel.capability.*event viaeventLog.append(...). The dispatch helper lives in a newexecutor/modelCapabilityGate.tsmodule to keepexecutor.tsfrom sprawling. - The capability-advertisement source of truth:
routes/discovery.ts'scapabilities.modelCapabilities.advertisedis populated by a newaiProviders/capabilityProbe.tsthat statically maps the configuredaiProviders.supported[]rows to a per-provider capability set. The probe is configured by a static table; future iterations could probe at startup. - Conformance fixture engineering: ~0.5 day per scenario × 4 = ~2 days. The static-discriminator scenario reuses the existing schema-walker from RFC 0030's Tier-1-subset scenario.
- Estimated total effort: schemas + spec text ~1 day; reference-host dispatch wiring ~1.5 days; four conformance scenarios ~2 days; CHANGELOG + INTEROP-MATRIX ~30 min. Total ~5 days plus the standard Active window unless the bootstrap-phase waiver applies.
Acceptance criteria
Promotion from Active → Accepted:
- [ ]
spec/v1/ai-envelope.mdextended with §"Variant payload discrimination (normative)" per §A. - [ ]
spec/v1/host-capabilities.mdextended with §"Model-capability declarations" describing the dispatch flow from §B. - [ ]
spec/v1/node-packs.mdextended with §"Model-capability declarations on NodeModules" referencing the schema additions in §B. - [ ]
schemas/node-pack-manifest.schema.jsongainsrequiredModelCapabilities+fallbackModelper §B. - [ ]
schemas/capabilities.schema.jsongains themodelCapabilitiesblock per §E. - [ ]
schemas/run-event.schema.jsonaddsmodel.capability.substituted+model.capability.insufficientto theRunEventTypeenum. - [ ]
schemas/run-event-payloads.schema.jsonaddsmodelCapabilitySubstituted+modelCapabilityInsufficient$defsand_typeIndexentries per §D. - [ ]
SECURITY/invariants.yamlgains themodel-capability-substituted-no-credential-disclosureentry per §F (gate timing: lands alongside reference-host implementation, matching RFC 0027 §G precedent). - [ ] Four new conformance scenarios per §"Conformance" land in
@openwop/openwop-conformance; suite minor-version bumps. - [ ] CHANGELOG entry under
[Unreleased]. - [ ]
INTEROP-MATRIX.mdextended with rows forcapabilities.modelCapabilities.supported,substitutionSupported, andadvertised[]. - [ ] Reference host (
apps/workflow-engine/backend/typescript) advertisescapabilities.modelCapabilities.supported: true, implements the dispatch gate, emits both new events, passes all four new conformance scenarios. - [ ] First non-steward host advertises
capabilities.modelCapabilities.supported: true(third-party validation gate per RFC 0001). MAY be waived under bootstrap-phase waiver.
References
RFCS/0021-ai-envelope-primitive.md— schema-discipline section this RFC tightens; vendor-namespaced-kind precedent for §A.RFCS/0030-envelope-reasoning-and-tier-one-subset.md— Tier-1 Compatibility Subset that §A'sanyOf+ discriminator pattern is a subset member of.RFCS/0032-envelope-reliability-events.md(forthcoming) — formally normates the line-448 MUST-NOT carve-out for cross-kind operational events; cited by reference from §D scope clarification.RFCS/0033-envelope-completion-contract.md(forthcoming) — depends on this RFC'smodel.capability.*events as part of the dispatch-time observability surface.RFCS/0026-provider-usage-event.md— precedent for adding cross-kind operational events toRunEventTypewithout conflicting with line 448.RFCS/0027-prompt-templates.md—prompt.composedprecedent for additive event-payload$defs.RFCS/0029-prompt-override-hierarchy.md§F — orthogonality note describing how this RFC'srequiredModelCapabilitiesinteracts with that RFC's prompt-resolution chain.spec/v1/capabilities.md§"Runtime capabilities" — the existingNodeModule.requiressurface this RFC complements with a parallelrequiredModelCapabilities.spec/v1/capabilities.md§aiProviders— provider/credential advertisement thatfallbackModel.providermust match.SECURITY/threat-model-secret-leakage.mdSR-1 — redaction harness referenced by §F.- OpenAI Structured Outputs — <https://developers.openai.com/api/docs/guides/structured-outputs>
- Anthropic Strict Tool Use — <https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use>
- Google Gemini Structured Output — <https://ai.google.dev/gemini-api/docs/structured-output>
- Instructor (discriminated-union retry pattern) — <https://github.com/jxnl/instructor>
- Pydantic AI output taxonomy — <https://pydantic.dev/docs/ai/core-concepts/output/>
Status history
Active → Accepted (2026-05-21)
Promoted to Accepted under the bootstrap-phase steward waiver per CONTRIBUTING.md §"Bootstrap-phase notes" + MAINTAINERS.md §"Bootstrap-phase RFC waivers" — same path RFCs 0021–0029 + RFC 0030 used. Zero external reviewers (single-steward bootstrap repo); all four acceptance criteria from RFCS/0001-rfc-process.md §"Promotion to Accepted" empirically met by Day 2.
Acceptance evidence:
1. Reference workflow-engine implementation. Host advertises capabilities.modelCapabilities.{supported: true, advertised: [5 reserved ids], substitutionSupported: false}. Execute-time model-capability gate in executor/executor.ts emits model.capability.{substituted,insufficient} and refuses with capability_not_provided. host/modelCapabilityProbe.ts + executor/modelCapabilityGate.ts (commits 903e8b6 + d65c49f). 2. Conformance suite coverage. model-capability-substituted.test.ts + model-capability-insufficient.test.ts + node-module-required-capabilities-shape.test.ts cover the §B 4-step dispatch flow + §C identifier registry + §D event emission. 13 live HTTP-gated assertions across the 3 files; end-to-end fixture-driven refusal verified via conformance-model-capability-insufficient. 3. Third-party host adoption. MyndHyve workflow-runtime at https://api.myndhyve.ai/.well-known/openwop advertises modelCapabilities.{supported: true, advertised: ["structured-output", "discriminator-enum"]} — the truthful subset per the §C 2026-05-21 amendment. 4. Adoption feedback folded. §C truthful-advertisement normation + §E substitutionSupported per-NodeModule scope clarification (amendments 2026-05-21, commit 9da6281).
Compatibility: ratification is non-normative — no wire surface, schema, or behavior changes.
Active amendment (2026-05-21) — MyndHyve adoption feedback
Additive normative-text clarification per the filled adoption feedback at docs/handoffs/MYNDHYVE-RFC-0030-0033-ADOPTION-FEEDBACK-2026-05-20.md §A.2. No wire-shape change; no schema change.
- §C — New normative paragraph on truthful advertisement:
capabilities.modelCapabilities.advertised[]MUST reflect only the capability identifiers the host actually gates on (referenced by some registeredNodeModule.requiredModelCapabilities[]). Boilerplate-paste of the full spec-reserved set is now explicitly non-conformant percapabilities.md§"Truthful advertisement." Surfaced by MyndHyve advertising only the 2 of 5 identifiers their pack registry references. - §E — Clarified
substitutionSupportedscope: substitution is per-NodeModule, not host-wide. Evaluated per dispatch of an envelope-emitting NodeModule (the unit at which the schema declaresrequiredModelCapabilities+fallbackModel). Hosts without a per-call provider-swap facility MUST advertisefalse— emittingmodel.capability.substitutedwithout actually substituting is wire-contract-dishonest. Surfaced by MyndHyve's substitution emission being node-config-scoped rather than host-dispatch-scoped.
Compatibility: additive per COMPATIBILITY.md §2.1. Hosts already advertising honestly (only the capabilities they gate on; only substitutionSupported: true when they actually substitute) remain compliant. The two clarifications make those existing best practices normative.
Draft → Active (2026-05-20)
Promoted under the bootstrap-phase steward waiver per the RFC 0021–0030 precedent. Spec text + wire-shape locked; conformance scenarios + SECURITY invariant + reference-host emission remain as the path to Accepted.
Evidence at promotion:
- Spec text:
- spec/v1/ai-envelope.md extended with §"Variant payload discrimination (normative)" between §"Schema discipline" and §"Envelope Contract". Normative SHALL: variant envelope payloads use anyOf + single-string-enum discriminator per branch; oneOf forbidden (cross-vendor incompatible — Gemini silently drops). Backward-compatibility paragraph preserves existing v1.x schemas that already use the pattern or that use oneOf/field-presence (no retroactive failure; forward-looking only). - spec/v1/host-capabilities.md extended with §"Model-capability declarations" between §host.aiEnvelope and §host.promptLibrary. Documents the 4-step dispatch flow (capability check → optional substitution → emit telemetry → dispatch or refuse), the 5-identifier spec-reserved capability registry (structured-output, discriminator-enum, long-context, reasoning, function-calling) + x-host-<host>-<key> extension prefix, and the orthogonality note with RFC 0029 prompt-resolution chain (capability check first, then prompt resolution — substitution can swap models with different prompt-tuning expectations). - spec/v1/node-packs.md extended with §"Model-capability declarations on NodeModules" between §"Per-node requiresSecrets[]" and §"Runtime formats." Author-side surface paralleling requiresSecrets[]. Cross-references the dispatch contract in host-capabilities.md.
- Schemas additive (no MUST relaxed):
- schemas/node-pack-manifest.schema.json — NodeModule shape gains optional requiredModelCapabilities: string[] (pattern restricts to spec-reserved or x-host-* identifiers, ≤32 entries, uniqueItems) + optional fallbackModel: { provider, model } with provider lowercase-ASCII pattern + model min-length-1. - schemas/capabilities.schema.json — new optional top-level modelCapabilities block with supported: boolean (required), advertised: string[], substitutionSupported: boolean. Sibling to the existing runtimeCapabilities array (host capabilities) — semantic distinction documented in both descriptions. - schemas/run-event.schema.json — RunEventType enum gains "model.capability.substituted" and "model.capability.insufficient" after agent.promptResolved (cross-kind operational cluster). - schemas/run-event-payloads.schema.json — _typeIndex gets two new entries; two new $defs (modelCapabilitySubstituted + modelCapabilityInsufficient) carry the payload contracts. Substitution event documents the all-or-nothing "[REDACTED]" redaction option for fallbackProvider/fallbackModel per SECURITY invariant model-capability-substituted-no-credential-disclosure. Insufficient event documents the no-recursive-fallback constraint (fallbackAttempted: true when the declared fallback itself failed, no chaining).
Path to Active → Accepted: requires the reference workflow-engine to (a) advertise capabilities.modelCapabilities.supported: true + advertised[] populated by a capability-probe over aiProviders.supported[], (b) implement the dispatch gate in executor/executor.ts (or executor/modelCapabilityGate.ts sibling) that consults the active model's advertised capabilities + emits model.capability.{substituted,insufficient}, (c) ship the four conformance scenarios from §"Conformance" (including the discriminator-static and substitution/insufficient/declaration shape assertions), and (d) ship the model-capability-substituted-no-credential-disclosure SECURITY invariant alongside the matching conformance scenario. Alternatively, a non-steward host advertising the capability closes the third-party validation gate per the RFC 0021 / RFC 0026 precedent.