OpenWOP openwop.dev
FieldValue
RFC0133
TitleWorkflow-chain composition — sub-chains and produced variables
StatusAccepted
Author(s)openwop-app maintainers
Created2026-07-22
Updated2026-07-23 (Active → Accepted: openwop-app tier-1 reference host witnessed the 5 conformance scenarios non-vacuously — rev e199bd96c, PR openwop-app#2429, @openwop/openwop-conformance@1.56.0 under OPENWOP_REQUIRE_BEHAVIOR=true: 22/22 tests, both capability-gated §B legs non-vacuous. sub-chain-child-tenant-scoped graduated reference-impl → protocol. See §"Status note".)
Affectsspec/v1/workflow-chain-packs.md, schemas/workflow-chain-pack-manifest.schema.json, schemas/workflow-definition.schema.json (no change; referenced), registry index, conformance scenarios
Compatibilityadditive per COMPATIBILITY.md
Supersedes
Superseded by

Summary

RFC 0013 established workflow-chain packs: registry-published DAG fragments a host expands into a concrete workflow. As adopted, a chain expands into exactly one flat WorkflowDefinition whose data flows only along edges and from author-time {{params.*}}. Two shapes real workflows use are therefore inexpressible as chains:

1. Composition — one workflow invoking another child workflow at run time (core.subWorkflow / a core.dispatch child-run fan-out). RFC 0013 §"WorkflowDefinitionFragment" anticipated this: "Forward-references to other chain packs MAY be allowed in a future RFC." This RFC is that future RFC. 2. Produced (run-scoped) variables — a node writes a value during the run that a downstream node reads by name (the run variable bag), where the value is not an author-time parameter and not carried on a typed output port.

This RFC adds both, additively: a chain fragment MAY reference sub-chains (which the host co-expands and co-registers so the parent holds a runtime child), and MAY declare produced variables that pass through to the expanded workflow's variable bag. No existing chain pack changes shape or behavior; both features are opt-in via new optional manifest fields.

Motivation

The reference host (openwop-app) has adopted a hard architectural rule: workflows are never hard-coded — a workflow is a chain (RFC 0013 pack) or a stack (kanban work-intake). An audit of its ~72 in-tree "builtin" workflows found that all but a handful convert to chains today; 5 cannot, blocked on exactly the two gaps above:

WorkflowBlocker
Challenge Factory (kicktodo.challenge-factory)both — invokes a lesson-batch child workflow (4× per checkpoint) AND threads produced vars (plan, batch0..3, evidenceSummary)
kicktodo.lesson-batchthe factory's child leg
kicktodo.plan-generationproduced var (generate writes plan, decompose reads it)
campaign-studio.campaign-orchestrationchild fan-out — 5× core.subWorkflow (seq) / core.dispatch child-run (parallel) over channel chains
kicktodo.enrollmentproduced var (enroll writes enrollmentId)

These are not exotic. Composition ("a workflow that runs sub-workflows") and a run-variable bag are core executor capabilities already used by hand-authored workflows and the core.subWorkflow / core.dispatch framework nodes. The chain pack format simply never exposed them. Rather than let these workflows stay hard-coded, this RFC teaches the pack format to express what the executor already runs — realizing the model statement "workflows can hold workflows."

Proposal

Two independent, additive extensions to the kind:"workflow-chain" manifest. Either MAY be used alone.

1. Sub-chain composition (runtime child chains)

A chain fragment MAY declare child chains it composes, and reference them from a runtime dispatch node. Unlike RFC 0013's author-time inline expansion, a referenced sub-chain is co-instantiated as its own registered workflow and invoked by the parent at run time — so the parent genuinely holds the child (both are owned, editable workflows after instantiation).

1.1 Manifest: subChains

A WorkflowChain MAY carry an optional subChains[]. Each entry names a chain the parent composes:

{
  "chainId": "kicktodo.challenge-factory",
  "parameters": { /* … */ },
  "dag": { /* … */ },
  "subChains": [
    { "ref": "lesson-batch" }                          // a sibling chain in THIS pack (by chainId)
    // or an external published chain:
    // { "ref": { "packName": "vendor.acme.presets", "chainId": "acme.enrich", "version": "^1" } }
  ]
}
  • ref is EITHER a string naming a sibling chainId in the same pack's

chains[], OR an object { packName, chainId, version } naming an externally published chain (resolved + signature-verified like any pack dependency).

  • A sibling ref that does not match a chainId in the same pack, or an external

ref that fails resolution/verification, is a manifest/expansion error (sub_chain_unresolved). Cycles (a chain that transitively composes itself) are rejected (sub_chain_cycle).

1.2 Referencing a sub-chain from the DAG

A fragment node invokes a declared sub-chain using the existing runtime composition nodes, with a new config.subChainRef in place of a hard-coded config.workflowId:

{ "id": "build-0", "typeId": "core.subWorkflow",
  "config": { "subChainRef": "lesson-batch" },
  "inputs": { /* mapped to the child's parameters/inputs */ } }
  • config.subChainRef MUST match a subChains[].ref sibling chainId (or the

chainId of an external ref). Using config.workflowId (a concrete id) inside a chain fragment remains INVALID (a chain must not pin a host-specific workflow id).

  • The same applies to core.dispatch with workerDispatchModel:"child-run": its

worker target is a subChainRef (or a list thereof), giving the parallel child-fan-out shape (this is RFC 0118's fan-out expressed at the chain layer).

1.3 Expansion + co-registration (host-side, normative)

When a host instantiates a parent chain (from-chain), it MUST:

1. Expand the parent fragment into a WorkflowDefinition as in RFC 0013. 2. For each distinct subChainRef reachable from the parent's nodes: resolve the referenced chain (sibling or external), recursively expand and register it as its own workflow (with recordOwnership for the same tenant), minting a deterministic id from (parentExpansionId, childChainId) so a repeat instantiation converges and a shared child is registered once. 3. Rewrite each referencing node's config.subChainRef to the minted child workflow id under the field the runtime already reads (config.workflowId for core.subWorkflow; the worker target for core.dispatch). The child reference IS preserved at runtime (unlike an author-time inline chain) — the parent dispatches the child as a child run. 4. Register the parent workflow. Parent and every co-registered child are owned + builder-editable; the parent surfaces its children as sub-workflow nodes.

Recursion is bounded by the cycle check (§1.1) and a host maxSubChainDepth (RECOMMENDED default 8). A host MAY offer sub-chain instantiation only where it supports runtime child dispatch; a host that does not MUST refuse with sub_chain_unsupported rather than silently flatten.

2. Produced (run-scoped) variables

RFC 0013 replaced a fragment's variables with author-time parameters. This RFC restores a separate, run-scoped channel for values produced during the run.

2.1 Prefer edges

Where a node exposes a value on a typed output port, chains MUST pass it via an explicit edge (sourceOutputtargetInput) — the chain-native dataflow. This section is ONLY for values a node writes to the run variable bag (no typed output port), which some framework/feature nodes do.

2.2 Manifest: producedVariables

A WorkflowChain MAY declare producedVariables[], each a value written by one node and read by others via the executor's { "type": "variable", "variableName": "…" } input binding:

{
  "chainId": "kicktodo.plan-generation",
  "dag": { /* … */ },
  "producedVariables": [
    { "name": "plan", "producedBy": "generate", "type": "object",
      "description": "the generated plan the decompose node consumes" }
  ]
}
  • name is the bag key. producedBy MUST be a nodes[].id in the same fragment.
  • type is a JSON-Schema type for validation/inspection. producedVariables are

run-scoped: they carry NO author-time value (that is what parameters are for) — the manifest DISTINGUISHES the two.

  • Any node input binding { type:"variable", variableName:"plan" } MUST reference a

declared producedVariables[].name (or an existing {{params.*}}-materialized parameter). An undeclared variable read is a manifest error (variable_undeclared) — closing the "reads a value nothing produces" hole.

2.3 Expansion

On expansion the host MUST emit the declared producedVariables into the resulting WorkflowDefinition.variables[] as run-scoped entries (name + type, no value), so the executor's existing variable bag carries them exactly as in a hand-authored workflow. No new runtime surface is needed — the executor already supports run variables; this only lets the pack format declare them.

3. Schema deltas (schemas/workflow-chain-pack-manifest.schema.json)

Additive, all optional:

  • WorkflowChain.subChains?: SubChainRef[] where

SubChainRef = { ref: string | { packName: string, chainId: string, version: string } }.

  • WorkflowChain.producedVariables?: ProducedVariable[] where

ProducedVariable = { name: string, producedBy: string, type: string, description?: string }.

  • FragmentNode.config MAY carry subChainRef: string (validated against

subChains). config.workflowId inside a fragment remains disallowed (add an explicit not guard).

No change to workflow-definition.schema.json — the expanded output uses its existing variables[] and core.subWorkflow/core.dispatch shapes.

Compatibility

Additive. Per COMPATIBILITY.md §2.2:

  • Every new field (subChains, producedVariables, config.subChainRef) is

OPTIONAL. Existing kind:"workflow-chain" manifests validate and expand byte-identically — a chain with none of these behaves exactly as under RFC 0013.

  • No run-event, dispatch, or wire shape changes. The expanded artifact a host

dispatches is a normal WorkflowDefinition (with variables[] + core.subWorkflow/core.dispatch nodes the executor already runs). A host that does not implement sub-chain instantiation refuses at author/instantiate time (sub_chain_unsupported) — it never mis-dispatches.

  • Registry index unchanged (chains still list by chainId).

Conformance

New capability-gated scenarios under conformance/ (chain-composition profile):

1. chain.subchain.sibling — a pack with a parent chain + a sibling child chain; from-chain co-registers both, mints a deterministic child id, rewrites the parent's subChainRef → child id; the parent run dispatches the child. 2. chain.subchain.fanout — a core.dispatch child-run over a subChainRef worker; N child runs, results collected. 3. chain.subchain.cycle-rejected — a self-referential chain fails manifest validation (sub_chain_cycle). 4. chain.subchain.unsupported-refused — a host without runtime child dispatch refuses (sub_chain_unsupported), never flattens. 5. chain.produced-var.roundtrip — a chain declaring producedVariables expands to a workflow whose variables[] carries them; producer writes, consumer reads; an undeclared variable read fails (variable_undeclared).

Alternatives considered

  • Author-time inline splicing of sub-chains (RFC 0013's model, extended). Flatten

the child into the parent at expansion. Rejected: it cannot express dynamic invocation (the Factory invokes lesson-batch per checkpoint with different inputs; a fan-out invokes N children) and it erases the child as an editable unit — the opposite of "workflows can hold workflows."

  • Edges-only; forbid produced variables. Require every hand-off to ride a typed

output→input edge. Rejected as the sole mechanism: some framework/feature nodes write to the run bag and expose no typed port, so an edges-only rule would force node rewrites purely to satisfy the pack format. Edges remain PREFERRED (§2.1); produced variables are the declared escape hatch, validated closed-world.

  • Keep composed/variable workflows hard-coded. Rejected by the host's doctrine —

a hard-coded workflow is invisible to the builder + author surfaces and not editable; the whole point is to retire the in-tree seam.

Unresolved questions

_Register sweep at Accepted (per RFCS/README.md §"Companion gap & risk registers"): every row below is closed or transferred to a tracked surface; the companion registers are 0133-workflow-chain-composition.gaps.md + 0133-workflow-chain-composition.risks.md._

1. External sub-chain versioning + trust — RESOLVED (pin). An external subChainRef pulls another pack into the trust + version-pin surface. Co-registration SHOULD pin the resolved concrete version into the parent's ownership record for reproducibility, so a later re-instantiation or :fork reproduces the same child. Encoded normatively in workflow-chain-packs.md §"Sub-chain composition (RFC 0133)" (External-ref version pinning) and in the schema SubChainRef.ref.version description. 2. Child ownership lifecycle — CARRIED FORWARD (tracked). When a parent is deleted, are co-registered children deleted too, or reference-counted (a child shared by two parents)? RECOMMENDED: reference-count by deterministic id; delete a child when its last parent is deleted. This is a host-side lifecycle concern, not a wire-shape concern, so it is carried forward as a named open gap — WCP5 in workflow-chain-packs.md §"Open spec gaps" + docs/KNOWN-LIMITS.md. 3. maxSubChainDepth default — RESOLVED (8). Confirmed RECOMMENDED default 8, encoded as capabilities.workflowChainPacks.subChains.maxDepth (default: 8) and DEFAULT_MAX_SUB_CHAIN_DEPTH in the reference library; a breach fails closed with sub_chain_cycle (the DoS backstop, AND-composed with the cycle check).

Status note (honest evidence boundary)

Draft → Active (2026-07-22) → Accepted (2026-07-23). The wire shapes are LOCKED, the full spec-side surface landed at Active, and the reference-host witness that gates Accepted is now in hand.

  • Witnessed server-free (non-vacuous): the schema deltas, the expandChainTree /

emitProducedVariables / validateVariableReads reference library, and the three server-free scenarios (chain-subchain-sibling, chain-subchain-cycle-rejected, chain-produced-var-roundtrip). The protocol-tier SECURITY invariant sub-chain-expansion-bounded is fully witnessed by chain-subchain-cycle-rejected.test.ts (self-composition rejects sub_chain_cycle; depth breach rejects the distinct sub_chain_max_depth_exceeded).

  • Witnessed on the tier-1 reference host (Active → Accepted gate — SATISFIED 2026-07-23):

end-to-end runtime child dispatch + from-chain co-registration on openwop-app rev e199bd96c (PR openwop-app#2429, ADR 0471). Against @openwop/openwop-conformance@1.56.0 under OPENWOP_REQUIRE_BEHAVIOR=true, all 5 scenarios / 22 tests PASS, and the two capability-gated §B legs (chain-subchain-fanout, chain-subchain-unsupported-refused) ran NON-VACUOUSLY — the host advertised capabilities.workflowChainPacks.subChains {supported:true, maxDepth:8} and the fanout §B asserted subChains.supported===true for real; in the negative posture (OPENWOP_CHAIN_SUBCHAINS=0) the host honestly omitted the block and the refusal assertion ran (sub_chain_unsupported, 2/2). Host-side behavioral coverage 15/15 (producedVariables emit, all 7 load codes, cycle + sub_chain_max_depth_exceeded, co-registration + deterministic tenant-scoped id + refuse-not-flatten). On this witness, sub-chain-child-tenant-scoped graduated reference-impl → protocol (RFC 0079 / RFC 0132 precedent). Second-host (non-steward) adoption remains tracked in docs/KNOWN-LIMITS.md as a strengthening, not a gate.

Implementation notes (non-normative)

Reference host (openwop-app): expandChain (host/workflowChainPackLoader.ts) gains recursion over subChains; POST …/workflows/from-chain co-registers each child (deterministic id, recordOwnership) and rewrites subChainRef → minted id before registerWorkflow. producedVariables map straight into the emitted WorkflowDefinition.variables[]. This unblocks the 5 audited workflows (Challenge Factory + lesson-batch, plan-generation, campaign-orchestration, enrollment) to become chains, per docs/builtin-workflow-migration-audit.md.

Acceptance criteria

  • [x] schemas/workflow-chain-pack-manifest.schema.json gains the optional

subChains, producedVariables, and config.subChainRef fields (+ the config not-guard forbidding a concrete workflowId); existing chain manifests still validate unchanged (verified by spec-corpus-validity + the chain-subchain-fanout §A legs).

  • [x] spec/v1/workflow-chain-packs.md documents §1 + §2 with the normative

expansion + co-registration + closed-world variable rules (+ §"Expansion semantics" step 9 / §"What hosts dispatch" amended for the opt-in runtime-child mode).

  • [x] The 3 server-free conformance scenarios pass (chain-subchain-sibling,

chain-subchain-cycle-rejected, chain-produced-var-roundtrip); the 2 capability-gated scenarios (chain-subchain-fanout, chain-subchain-unsupported-refused) are authored and soft-skip until a reference host wires runtime child dispatch (host-pending — see §"Status note").

  • [x] capabilities.workflowChainPacks.subChains advertised; sub_chain_unresolved /

sub_chain_cycle / sub_chain_unsupported / variable_undeclared registered in §"Error codes"; SECURITY invariants sub-chain-expansion-bounded (protocol) + sub-chain-child-tenant-scoped (reference-impl) landed.

  • [ ] A worked example (the Challenge Factory + lesson-batch as a two-chain pack) lands as

a conformance fixture + in openwop/openwop-examples (examples/ was extracted to that repo — carried forward, host-pending).

References

  • RFC 0013 — Workflow-chain packs (the base this extends; its "future RFC" note on

forward-references).

  • RFC 0118 — parallel dispatch / fan-out (the core.dispatch child-run model §1.2

mirrors at the chain layer).

  • openwop-app docs/builtin-workflow-migration-audit.md — the 5 workflows this

unblocks; ARCHITECTURE.md / CLAUDE.md "Workflows — never hard-code" doctrine.

  • ADR 0072 (openwop-app) — the deprecated in-tree builtinWorkflows seam this

retires.