| Field | Value |
|---|---|
| RFC | 0157 |
| Title | Chain fragments carry compensation (RFC 0013 revision × RFC 0151 §B) |
| Status | Accepted |
| Author(s) | David Tufts (@davidscotttufts); measured and requested by the openwop-app maintainers (ADR 0554 P2) |
| Created | 2026-08-16 |
| Updated | 2026-08-16 (Draft → Accepted same day: schema mirrors + spec prose + reference expansion + conformance landed together; 7-day comment window waived by the steward per MAINTAINERS.md §"Bootstrap-phase RFC waivers", the RFC 0134/0135 precedent for RFC 0013 revisions. Landed: the wire and the suite. Carried, not closed: host adoption — the in-memory reference host's byte-mirrored expansion core does not yet run carryCompensation, and no host expands a compensating chain non-vacuously.) |
| Affects | schemas/workflow-chain-pack-manifest.schema.json ($defs.FragmentNode.compensation, $defs.WorkflowChain.compensation), spec/v1/workflow-chain-packs.md (§"Compensation (RFC 0157)", expansion steps 3/5/6/9, error codes), conformance/src/lib/workflow-chain-expansion.ts (carryCompensation, expandChainWithCompensation), NEW conformance/src/scenarios/chain-compensation-expansion.test.ts |
| Compatibility | additive per COMPATIBILITY.md §2.1 |
| Supersedes | — |
| Superseded by | — |
Summary
RFC 0151 §B lets a WorkflowNode declare its inverse action (compensation { nodeTypeId, inputMapping?, retry?, requiresApproval? }) and, since #1009, lets a workflow carry a compensation policy at settings.compensation. RFC 0013 workflow-chain packs mirror WorkflowNode into FragmentNode — and the mirror stopped at id/typeId/name/position/config/inputs. FragmentNode's own description says new WorkflowNode fields must be mirrored; compensation (b4796755) was not. So on a host where every workflow is a chain or a stack, no node could own an inverse action, no chain could carry a policy, and no unwind could ever occur: RFC 0151 §B was reachable only through a hand-authored POST /v1/workflows. This RFC mirrors both surfaces into the chain manifest and defines how they survive expansion into the registered WorkflowDefinition.
Motivation
Measured by the openwop-app maintainers while implementing ADR 0554 P2 (2026-08-16): schemas/workflow-chain-pack-manifest.schema.json $defs.FragmentNode carries exactly ['id','typeId','name','position','config','inputs'] and the manifest mentions compensation nowhere; the host's expandChain builds expanded nodes from that same allowlist. The host did not add a host-private key — a guess at a contract with an owner is the failure ADR 0548 invariant 4 exists to prevent — and asked for the revision instead. Two things are needed: (1) a fragment node that can declare its compensator, and (2) a chain-level policy that arrives in settings.compensation on the registered definition, so a chain-first host can reach RFC 0151 §B at all.
Proposal
§A — FragmentNode.compensation
$defs.FragmentNode gains OPTIONAL compensation, a byte-mirror of workflow-definition.schema.json#/$defs/WorkflowNode/properties/compensation (closed; nodeTypeId REQUIRED). It is descriptive — it says what the inverse action is — and any host MUST carry it through expansion verbatim, whether or not it advertises capabilities.compensation.
§B — WorkflowChain.compensation
$defs.WorkflowChain gains OPTIONAL compensation, a byte-mirror of compensation-policy.schema.json (RFC 0151 §B; closed; triggers REQUIRED). It requests an unwind. On expansion it becomes the registered definition's settings.compensation:
- parent has none ⇒ copy;
- parent has one that is deep-equal (key-order insensitive) ⇒ accept;
- otherwise ⇒
chain_compensation_policy_conflict(HTTP409,details.chainId). Expansion MUST NOT merge policies — a merged policy nobody wrote is exactly the guess-at-a-contract failure the policy exists to prevent; the author reconciles and re-expands.
A host that does not advertise capabilities.compensation MUST refuse a chain carrying a policy with capability_required (compensation.md §"Workflow policy") — the same rule as a hand-authored settings.compensation.
§C — Expansion rules
Numbered against workflow-chain-packs.md §"Expansion semantics":
- 3b — every
compensation.nodeTypeIdMUST resolve exactly astypeIddoes (chain_unresolvable_typeid), so an unwind cannot fail on a typo first discovered during a failure. - 5b —
{{params.<name>}}insideinputMappingMUST be substituted at expansion (author-time literals; RFC 0151 §B's recorded-facts rule is unaffected). - 6b — fragment node-id references inside
inputMapping(${nodes.<id>.…}/nodes.<id>.…) MUST be rewritten with the expansion prefix exactly as edge refs are; references to non-fragment ids pass through. - 9b — the chain-level policy is carried per §B before persistence.
§D — Mirrors, not $refs
Both surfaces are copied inline; the manifest schema keeps only #/… refs. A new cross-file $ref in a schema is a suite-minor change every downstream fixed-list validator must absorb (fixtures-valid.test.ts learned this from compensation-policy.schema.json on 2026-08-16). Two conformance legs assert each mirror stays byte-equal (apart from its own description) to its source, so drift cannot be silent.
§E — Reference implementation placement
conformance/src/lib/workflow-chain-expansion.ts gains carryCompensation(chain, expanded, ctx, parentSettingsCompensation?) and expandChainWithCompensation(...), composed after the byte-mirrored core expandChain rather than inside it — the core is mirrored verbatim by the in-memory reference host and gated in CI (check-workflow-chain-expansion-sync.mjs), and this pass composes on its output. A host runs the core, then the carry.
Compatibility
Additive. Two new OPTIONAL properties on closed objects; a chain that declares neither is byte-identical and expands identically. Existing chains, hosts, and registries are unaffected. Lands in v1.x. A host adopts it by extending its expander; until it does, a manifest carrying either surface fails that host's schema validation of the pinned manifest schema (honest — the host does not yet carry it, so it must not accept it silently). Downstream validators with fixed ref lists are unaffected (§D).
Conformance
chain-compensation-expansion.test.ts (server-free): the two mirrors; a manifest with both surfaces validates and a foreign key inside either is rejected; the manifest stays self-contained; expansion carries the declaration verbatim with params substituted and id refs rewritten; an unresolvable compensator fails chain_unresolvable_typeid before any node is emitted; the policy is copied / accepted / conflicted. Suite 1.110.0 → 1.111.0.
The host half — a host's own expander honouring 3b/5b/6b/9b — is witnessed through the live workflow-chain-host-expansion path once a host adopts this RFC. Per RFC 0147 §A.5 that host witness is what would substantiate a behavioural claim; this RFC lands the wire and the reference, and says so.
Security
Same tier as RFC 0151 §B (security-high, RFC 0147 R9): a compensation is a second effect. Nothing here widens it — the declaration is descriptive, the policy can only escalate approval scope, and the conflict rule fails closed. inputMapping substitution happens at author time from author-supplied parameters (never from a model or a peer), so compensation-input-recorded-facts-only is preserved. No new invariant.
Unresolved questions
1. Deferred mode (RFC 0124): should {{params.*}} inside inputMapping be deferrable per run like config/inputs tokens? Deferred — frozen at drop time for now (workflow-chain-packs.md WCP6); a compensator's inputs varying per run is a design question RFC 0151 §B's recorded-facts rule should answer first. 2. Sub-chain co-expansion (RFC 0133): does a child chain's policy conflict with the parent's, or apply only to the child's co-registered definition? Resolved for v1: it applies to the child's own registered definition (each co-registered workflow owns its settings); a parent–child policy difference is not a conflict.
Acceptance criteria
- [x] Schema mirrors land, closed, self-contained, byte-equal to their sources under test.
- [x] Spec prose lands (
workflow-chain-packs.md§"Compensation (RFC 0157)", steps 3/5/6/9, error code, gap WCP6). - [x] Reference expansion lands below the mirrored core; server-free witness passes.
- [ ] A host expands a compensating chain non-vacuously through the live
workflow-chain-host-expansionpath. (Witness landed 2026-08-16, suite 1.133.0: the bundledvendor.openwop.workflow-chain-samplefixture (pack 1.1.0) gainsreserve-and-notify(nodecompensation+irreversibleEffect, no policy) andreserve-and-notify-with-policy(adds the chain policy); two legs inworkflow-chain-host-expansion.test.tsexpand them through the host seam and compare withexpandChainWithCompensation— declarations verbatim on the expanded nodes; the policy chain refusedcapability_requiredon a non-advertising host, expanded on an advertising one. openwop-app main1b2dd6fbb(H13, #3292) carries both through its expander; its seam serves the fixture from its conformance pin (1.130.0), so the legs recordblockedthere until the pin reaches 1.133.0 — then this box is measured, not merely witnessable.) - [ ] The in-memory reference host's mirrored core adopts
carryCompensation. (Carried —openwop-examples.)
References
- RFC 0013 — Workflow-chain packs · RFC 0124 · RFC 0133 · RFC 0134 · RFC 0135 — prior RFC 0013 revisions
- RFC 0151 — Compensation and Partial-Failure Profile ·
spec/v1/compensation.md·schemas/compensation-policy.schema.json spec/v1/workflow-chain-packs.md·schemas/workflow-chain-pack-manifest.schema.json