OpenWOP openwop.dev
FieldValue
RFC0151
TitleCompensation and Partial-Failure Profile
StatusAccepted
Author(s)David Tufts (@davidscotttufts)
Created2026-08-11
Updated2026-08-17 (S37: waiveRequiresApproval escalation is a FLOOR — effective = (declared ?? declared requiresApproval) OR approvalScope === "all"; a node-level false wins over the node's own default but never over policy escalation, parity with approvalScope's escalate-only rule; asked by the implementing host, which found the S36 text readable both ways.) 2026-08-17 later still (S36: See Amendment record.
AffectsNEW spec/v1/compensation.md, NEW schemas/compensation-policy.schema.json, schemas/{workflow-definition,run-event-payloads,capabilities}.schema.json, OpenAPI run control, AsyncAPI run events, replay/idempotency/interrupt/dead-letter specs
Compatibilityadditive per COMPATIBILITY.md §2.1
Supersedes
Superseded by

Summary

This RFC adds an optional openwop-compensation-v1 profile for durable Saga-style recovery of external business effects. Workflows may associate an effectful node with an idempotent compensation action, and hosts persist reverse-unwind progress, partial failure, approvals, retries, dead-letter routing, and operator intervention. The profile does not promise atomic rollback; it standardizes best-effort compensating execution and evidence.

Motivation

OpenWOP defines retries, cancellation, replay suppression, and dead-letter routing, but none reverses a payment, message, reservation, or external mutation that already committed. Multi-step workflows need a portable way to declare inverse actions, observe partial compensation, and resume safely after a crash. This is wire-level workflow behavior shared by independent hosts, not a storage or UI choice.

Proposal

§A — Capability

 {
+  "compensation": {
+    "supported": true,
+    "profileVersion": "1",
+    "orderingModels": ["reverse-completion", "dependency-graph"],
+    "manualIntervention": true
+  }
 }

Absent means no generic compensation contract. An advertising host MUST implement reverse-completion; it MAY additionally implement dependency-graph.

§B — Workflow declaration

An effectful node MAY declare:

 {
   "id": "reserve-inventory",
   "typeId": "vendor.shop.reserve",
+  "compensation": {
+    "nodeTypeId": "vendor.shop.release",
+    "inputMapping": { "reservationId": "${nodes.reserve-inventory.output.id}" },
+    "retry": { "maxAttempts": 5, "backoffMs": 1000 },
+    "requiresApproval": false
+  }
 }

compensation is optional and closed. nodeTypeId MUST resolve at registration. Inputs MUST derive from recorded facts; prompt/model regeneration MUST NOT construct a compensation input during replay. A host MUST reject a compensation cycle.

§C — Lifecycle

When a forward failure triggers unwind, the host MUST persist a compensation plan before executing its first inverse action. reverse-completion orders compensations by descending durable forward-completion sequence. dependency-graph MUST be a DAG and preserve reverse dependency order.

Each inverse action receives a stable ID derived from (tenantId, runId, forwardLogicalInvocationId, compensationOrdinal, profileVersion) and MUST be retry-stable. Crash recovery MUST resume from persisted state. Cancellation of the parent MUST NOT silently abandon an active compensation; it either continues, pauses for authorized intervention, or records manual intervention required.

§D — Events and state

Add content-free events:

  • compensation.requested;
  • compensation.started;
  • compensation.completed;
  • compensation.failed;
  • compensation.paused; and
  • compensation.manual_intervention_required.

The run retains its existing terminal execution state and adds optional compensationStatus: none|pending|running|completed|partial|failed|manual. This avoids reinterpreting existing run-state enums. Event payloads carry opaque node/effect IDs, attempt, ordering model, and closed reason codes—never provider bodies or credentials.

§E — Approvals, DLQ, and operator recovery

requiresApproval:true creates an RFC 0051 approval interrupt before the inverse effect. Approval authorization uses RFC 0049 and MUST bind tenant, principal, action, and plan version. Exhausted compensation retries MUST route to RFC 0053 dead-letter handling and set partial, failed, or manual. An authorized operator MAY retry, skip with recorded justification, substitute a registered compensation action, or terminate as uncompensated. Every override MUST be audited.

§F — Replay and branch

Replay defaults MUST use recorded compensation outcomes and MUST NOT re-fire inverse effects. A live-effect branch MAY execute compensation only after explicit authorization and fresh effect IDs. Forking a partially compensated run MUST preserve source facts without claiming that the branch changed the source system.

§G — Security

Add invariants compensation-effect-id-retry-stable, compensation-tenant-authority-bound, compensation-replay-no-refire, and compensation-input-recorded-facts-only. Compensation credentials use normal BYOK/egress policy and least privilege; forward credentials MUST NOT be copied into the plan. External audit scope includes double compensation, authority escalation, poisoned inverse mappings, and manual override.

Compatibility

Additive and capability-gated. New optional workflow fields, discovery block, events, and response field are ignored by existing consumers under v1 rules. A host omitting the capability remains conformant. Existing workflows have compensationStatus:none. No generic rollback is inferred from an undeclared action.

Conformance

New scenarios and fixtures:

  • compensation-shape.test.ts and positive/negative workflow fixtures;
  • compensation-reverse-unwind.test.ts;
  • compensation-idempotent-retry.test.ts;
  • compensation-crash-recovery.test.ts;
  • compensation-partial-manual.test.ts;
  • compensation-approval-authority.test.ts;
  • compensation-replay-no-refire.test.ts; and
  • compensation-tenant-isolation.test.ts.

Shape tests are always-on. Behavior gates on compensation.supported; an advertising host must execute all mandatory reverse-completion legs in strict certification. Reference hosts provide deterministic fake-effect fixtures plus one sandboxed real-service emulator.

Alternatives considered

1. Treat cancellation as rollback. Rejected: committed effects remain. 2. Require application-authored repair workflows only. Rejected: no portable lifecycle or evidence. 3. Require distributed transactions. Rejected: most external services do not participate. 4. Make compensation core. Rejected: simple/read-only workflows do not need it. 5. Do nothing. Rejected: partial business failure remains operator-specific and opaque.

Unresolved questions

1. Should dependency-graph ordering be v1 or a later profile version? 2. ~~May an operator substitute a different node type without changing the plan version?~~ Resolved 2026-08-16: no. spec/v1/compensation.md §E — substitution increments planVersion, and approvals / authorization decisions bound to the prior version are void, so an approval recorded for one inverse action cannot carry over to a different one. 3. ~~Which run response owns compensationStatus?~~ Resolved 2026-08-16: RunSnapshot (GET /v1/runs/{runId}, run-snapshot.schema.json) is the sole owner; debug bundles and the AsyncAPI run.snapshot reuse it by $ref. OPTIONAL and capability-gated — a host that does not advertise compensation MUST omit it, an advertising host MUST carry it (none when idle) — and its value is the deterministic fold of the §D events, defined normatively in spec/v1/compensation.md §"Run rollup". A §E approval pause does not change it (the run's own status: waiting-approval carries the wait), which is the concrete payoff of keeping the two fields separate. What this does NOT settle: the value on a forked run of a partially compensated source (compensation.md G4). 4. ~~How are irreversible effects declared so authors cannot imply a compensator exists?~~ Resolved 2026-08-16: an OPTIONAL node-level boolean irreversibleEffect (sibling of compensation, mutually exclusive by schema; mirrored into RFC 0157 chain fragments). A committed irreversible effect enters the plan as an irreversible entry that never completes, so compensationStatus caps at partial. Deliberately a sibling, not a compensation variant, so nodeTypeId stays required and COMPATIBILITY §2.2 is not engaged. spec/v1/compensation.md §B "Irreversible effects". 5. ~~What minimum retention applies to compensation evidence?~~ **Resolved 2026-08-18: a MINIMUM, never a window — and the requirement is stated so that retaining evidence longer is conformant. A tier-1 host supplied the measurement rather than an estimate: its obligations live in a durable collection with no retention purger registered at all**, so they are not time-purged; they are removed only by DSAR subject erasure. Its run retention is off by default (defaultRetentionDays() returns 0 unless configured) and the sweep daemon is gated behind a flag whose own docblock reads "DESTRUCTIVE → default OFF".

Two things follow. (a) Any minimum this RFC sets is met trivially by that host, so a minimum is cheap to comply with and still worth stating — an unwind's evidence outliving the incident it documents is the property worth guaranteeing. (b) The dangerous direction is the opposite one: if this were ever written as a maximum or a window, that host has no mechanism to comply and would need a purger built for it. A retention ceiling on compensation evidence would also sit awkwardly beside RFC 0053 dead-letter handling and the audit chain, which exist to keep failure evidence.

So the resolution is: compensation evidence MUST be retained at least as long as the run's own event log, and a host MAY retain it longer — including indefinitely. Erasure on a lawful subject-erasure request remains permitted and is not a retention violation. No maximum is specified, and one SHOULD NOT be added without naming the host mechanism that would enforce it.

Implementation notes (non-normative)

Implement after RFC 0150's effect identities. Start with reverse-completion and a deterministic effect emulator. This RFC is SR-4 under RFC 0147.

Acceptance criteria

  • [ ] Spec, policy schema, capability, events, and API/AsyncAPI projections land. (Events landed — six content-free compensation.* types in the closed RunEventType enum, payload schemas, and AsyncAPI messages; without them a host following §D would have emitted schema-invalid events. Capability family and node-level compensation declaration landed in capabilities.schema.json and workflow-definition.schema.json, with compensation-profile.test.tsshape only. 2026-08-16: compensationStatus landed on RunSnapshot (UQ3), and spec/v1/compensation.md opened at Draftthe landed subset only: §A/§B/§D/§F, with §C lifecycle prose, §E operator recovery, compensation-policy.schema.json, and fork semantics named as carried in its own gap table. Later 2026-08-16: schemas/compensation-policy.schema.json landed as the reserved settings.compensation workflow key — closed triggers, ordering model, retry/timeout defaults, exhaustedDisposition, escalate-only approvalScope, onParentCancel — with the non-advertising-host refusal rule (capability_required); it was the program's last absent Affects artifact. Carried: §C/§E prose beyond what the policy names, and the OpenAPI/AsyncAPI projections beyond the snapshot $ref.)
  • [ ] Reverse-unwind, retry, crash, partial/manual, approval, replay, and isolation scenarios pass. (2026-08-17: they PASS on a host — openwop-app main d209d8009 (ADR 0554 wire flip, #3294), local boot, strict, suite 1.134.0: compensation-behavior 6/6 (25 asserts), compensation-recovery 3/3 (25), chain-compensation-expansion 11/11 — reverse-unwind, retry-stable identity, partial/manual via the held plan + operator triple, replay ≡ source, tenant isolation; the first host to execute every RFC 0151 normative behavioral path in strict mode (RFC 0147 §A.5). Still absent: crash-resume and approval-before-inverse legs; deployed-origin run pending deploy #2. 2026-08-16 later: retry, partial/manual, isolation, and recorded-facts replay legs now existcompensation-recovery.test.ts through the §21 recovery extension (retry-stable identity with one downstream key; a held plan + operator seam driven cross-tenant / non-operator / operator; replayed ≡ source). Still absent: crash-resume (needs a seam that kills the host mid-unwind — reference-host work) and approval-before-inverse (needs an RFC 0051 interrupt through the seam). The witness now exists: compensation-behavior.test.ts covers plan-before-first-effect, reverse-completion ordering, replay-no-refire, and credential exclusion, capability-gated and strict-mode-failing. 2026-08-16: a sixth leg reads the seam run's snapshot and asserts the §D rollup against the emitted events, and the seams themselves are finally catalogued (host-sample-test-seams.md §21) — for three suite minors the witness had driven /v1/host/sample/test/compensation/{unwind,replay} with no contract for a host to build against. Carried: a host that advertises compensation.supported and exposes the seams — until one does, these resolve to blocked per RFC 0148 §A.)
  • [ ] Effect IDs compose with RFC 0150. (Updated 2026-08-18 — SP-11a: the blocker named here is gone. RFC 0150 §B landed logicalInvocationId and §C's semantic request digest v2 landed 2026-08-13 (730ff3despec/v1/replay.md §"Semantic request digest v2", recipe stamp openwop-semantic-request-v2, golden vectors 40617a4d), so both halves of the composition target are now fixed and this item is no longer waiting on RFC 0150. What remains is writing the composition itself: stating in §C how an inverse action's identity tuple composes with logicalInvocationId + the v2 digest, and a vector proving a retried inverse presents the same identity to the same downstream. The witness surface already exists — the §21 recovery extension reports effectId + downstreamKeys per attempt, and compensation-recovery.test.ts asserts one distinct downstream key across three attempts — so this is a specification gap, not an observability one.)
  • [x] Threat models and invariants land. (2026-08-16: threat model landed — SECURITY/threat-model-compensation.md, indexed in SECURITY.md; compensation-replay-no-refire registered and re-homed to it. Later the same day: compensation-effect-id-retry-stable, compensation-tenant-authority-bound, compensation-input-recorded-facts-only registered at protocol tier against compensation-recovery.test.ts and the §21 recovery extension it drives — all four §G invariants registered; each blocked in the ledger until a host wires the extension.)
  • [ ] One reference host demonstrates non-vacuous recovery from a mid-unwind crash. (Carried — reference hosts live in openwop-examples, and there is no compensation surface for one to implement.) 2026-08-18 (SP-11a) — what exists today, so the gap is not overstated: the §21 recovery extension gives a STORAGE-level witness of the adversary this box is about — failFirstInverseAttempts: n makes the first inverse action fail transiently and compensation-recovery.test.ts asserts the retry re-presents the same effectId and the same downstream idempotency key across attempts, which is the property a mid-unwind crash threatens. That is not what this box asks for: a seam-driven transient failure is not a process death, and a host that mounts the seam has not shown its plan survives a crash between the inverse action committing downstream and the host recording that it did. The distinction is the whole point of the box, so it stays open — but the residual is 'kill the process mid-unwind and resume', not 'no witness of retry-stability exists'.

2026-08-18 — the criterion is REWORDED, because as written it was not demonstrable and a tier-1 host supplied the derivation. On that host compensation runs inside the executor (unwindTerminatedRun is called from executor.ts) and holds no lease of its own, so recovery after an executor death waits on the run's dispatch lease — RUN_DISPATCH_LEASE_MS = the run-duration ceiling (600s) + 120s = 12 minutes. A mid-unwind crash test is therefore either a twelve-minute test or a race between setImmediate and SIGKILL, and the bound cannot simply be shortened for testing: the ceiling is what the host advertises as capabilities.limits.maxRunDurationMs. The old wording implied a fast test and so implied a host architecture — a compensation runner with its own short lease — that this profile never required.

The criterion is therefore: **a reference host demonstrates non-vacuous recovery from a mid-unwind crash within a host-declared bound, and that bound is advertised.** A twelve-minute recovery is a legitimate posture; an undeclared recovery bound is not, and that is the property worth requiring. The reworded criterion is satisfiable by a host that recovers slowly and says so, and unsatisfiable by one that recovers at an unknown time — which is the correct discrimination and the opposite of what the old text selected for.

This belongs in RFC 0158 (durable execution / DR qualification) rather than here, and moves there when 0158 is authored: "recovery within a declared bound" is the general durability property, of which mid-unwind compensation is one instance. It stays in this box until 0158 exists so nothing is dropped in transit.

  • [ ] SDK types, fixtures catalog, interop matrix, and CHANGELOG update. (Carried with the spec above.)

Amendment record

Change history relocated from the Updated metadata cell (newest first).

  • WorkflowNode.compensation.waiveRequiresApproval — OPTIONAL boolean gating §E skip/terminate behind RFC 0051 approval, default = effective requiresApproval, mint-time stamped, not for substitute, no policy counterpart — asked by the first host implementing operator recovery (openwop-app ADR 0554 P3), which had derived high-risk from requiresApproval and refused to invent a heuristic;.
  • additive per COMPATIBILITY §2..) 2026-08-17 later (DEPLOYED-WIRE: app.openwop.dev 756a9938d rev 00646-2zb advertises compensation and carries compensationStatus; authenticated strict pass on the origin — compensation-profile 19/19, compensation-behavior 5/6 [advert, §B, §D rollup ⇄ events, reverse-completion order, replay-no-refire on the wire; plan-before-first-inverse.
  • seamAbsent], compensation-recovery 0/3 seamAbsent — §C/§E/§F remain LOCAL-BOOT claims because their only observation path is a §21 test seam; new gap G9 in compensation.md names the missing normative observation surface, carried with G6/G7.) 2026-08-17 (first host witness — openwop-app d209d8009 advertises compensation, executes compensation-behavior 6/6 + compensation-recovery 3/3.
  • strict on a local boot; INTEROP-MATRIX row added; four §G invariants witnessed.) 2026-08-16 (UQ4 resolved — WorkflowNode.irreversibleEffect + FragmentNode.irreversibleEffect, mutual exclusion with compensation in schema, plan entry irreversible, rollup caps at partial; suite 1.129.0 -> 1.130.0.) 2026-08-16 (§21 recovery extension + compensation-recovery.test.ts — the three §G.
  • invariants that were named-not-registered now have a witness and are registered; suite 1.128.0 -> 1.129.0.) 2026-08-16 (§C/§E/§G prose landed in spec/v1/compensation.md — plan, identity tuple with attempt outside it, crash-resume, onParentCancel; approval-before-effect on the plan entry, RFC 0049 binding to tenant/principal/action/planVersion, RFC 0053 routing, the four operator actions.
  • as wire-defined outcomes; UQ2 decided: substitution is a new planVersion.
  • NEW SECURITY/threat-model-compensation.md (T1–T6, A1–A9, STRIDE per §); compensation-replay-no-refire re-homed to it; the other three §G invariants named-not-registered with the seam extension each needs.
  • Doc gaps G1/G2 closed, G6/G7/G8 opened (pause reasons, operator endpoints, irreversible declaration = UQ4).) 2026-08-16 (compensation-policy.schema.json landed as settings.compensation, closing the program's last Affects absence; suite 1.107.0 -> 1.108.0).
  • 2026-08-16 (UQ3 resolved — compensationStatus lands on RunSnapshot, spec/v1/compensation.md opens at Draft covering the landed subset, and the unwind/replay seams the behavioral witness had driven since suite 1.94.0 are catalogued in host-sample-test-seams.md §21; suite 1.106.1 -> 1.107.0).
  • 2026-08-12 (Active -> Accepted; 7-day comment window waived by the steward per MAINTAINERS.md §"Bootstrap-phase RFC waivers".
  • Landed: RFC text and its gap/risk registers. Carried forward, not closed: the entire compensation and partial-failure profile — schema, prose, conformance, and host implementation.).

References

  • RFCs 0051, 0053, 0140, 0150, and 0147 Workstream 4
  • AWS Prescriptive Guidance, Saga patterns
  • Azure Durable Functions and Temporal durable execution