0018-single-commit-pipeline-as-plain-function.md 5.3 KB

Single-commit pipeline as a plain function, legend for multi-transfer only

  • Status: accepted
  • Authors: Cesar Rodas
  • Date: 2026-07-15
  • Targeted modules: kuatia (ledger::commit, saga)
  • Associated tickets/PRs: N/A

Context and Problem Statement

ADR-0002 chose a saga commit pipeline (reserve -> finalize) driven by the legend crate, and listed as a positive consequence that "the same steps drive both a single commit and multi-transfer workflows." In the code this did not hold literally: multi-transfer workflows compose PayMovementStep and DepositMovementStep, which each wrap commit(), while ReservePostingsStep and FinalizeTransferStep were used only by the single-commit EnvelopeSaga.

Driving one fixed two-step sequence through legend cost a four-hop call path (commit_envelope -> drive_envelope_saga -> FinalizeTransferStep::execute -> Ledger::finalize_envelope) plus a legend! macro in a separate file. The finalize step was a trampoline that only forwarded to finalize_envelope, and its compensation was dead code: legend compensates completed steps in LIFO order, so a failing terminal step never runs its own compensation. Should the single commit keep going through legend, or is it clearer as a plain function?

Decision Drivers

  • Navigability: reading "how a commit finalizes" should not require bouncing across three files and a macro expansion.
  • Honesty of the abstraction: the durability engine is the phase-tracked write-ahead record plus recover() (ADR-0003), not legend. The saga framework contributed only per-step retry and LIFO compensation to the single commit, and half of that (the terminal step's compensation) was unreachable.
  • Preserve behavior exactly: retry counts, the release-on-failure compensation, the point-of-no-return semantics, and the recovery model must not change.
  • Keep real composition: multi-transfer workflows must still compose with workflow-wide compensation.

Considered Options

Option 1: Keep legend driving the single commit (status quo)

Pros:

  • Good, because it matches ADR-0002's wording literally.
  • Good, because retry and compensation are expressed through one framework.

Cons:

  • Bad, because the single commit is a fixed two-step line, so the saga machinery buys little: one trampoline step and one unreachable compensation.
  • Bad, because the control flow spans commit_envelope, saga.rs, envelope_saga.rs, and the legend! macro for one straight-line concept.

Option 2: Drive the single commit with a plain function; keep legend for multi-transfer

Replace EnvelopeSaga with a drive_envelope_saga function that runs reserve -> finalize directly: retry each phase, and on a finalize failure release the reservation (a no-op past the point of no return, where recovery rolls forward). Delete ReservePostingsStep, FinalizeTransferStep, and envelope_saga.rs. Keep PayMovementStep/DepositMovementStep and the legend!-composed multi-transfer sagas unchanged.

Pros:

  • Good, because the commit reads top to bottom in one place, next to finalize_envelope and recover.
  • Good, because it removes the trampoline and the unreachable compensation.
  • Good, because it names the truth: legend earns its keep across transfers (real cross-step reversal), not within one commit.

Cons:

  • Bad, because the retry-and-compensate logic is now hand-written rather than provided by the framework, so it must be kept correct by tests.
  • Bad, because it diverges from ADR-0002's "the same steps drive both."

Decision Outcome

Chosen option: Option 2. The single commit becomes a plain function; the legend saga is reserved for multi-transfer composition, which is the only place its cross-step retry and LIFO compensation are actually exercised. This refines ADR-0002: the two-phase reserve -> finalize structure, the reservation protocol, and the write-ahead recovery model are all unchanged. Only the driver of the single commit changes, from a legend execution to a function that replicates the same retry and compensation semantics.

Positive Consequences

  • One reading path for a commit: commit_envelope -> drive_envelope_saga (reserve, then finalize) -> finalize_envelope.
  • The unreachable terminal-step compensation and the forwarding step are gone.
  • The dumb-storage count contract stays centralized in saga.rs (verify_postings, verify_posting_states, ensure) and is shared by the reserve phase and by finalize_envelope.

Negative Consequences

  • Per-phase retry and the release-on-failure compensation are now explicit code in drive_envelope_saga, covered by the recovery tests (which exercise this function directly): a Reserving saga that fails re-validation releases the reservation and aborts; a taken posting makes the reserve phase fail without release.
  • ADR-0002's "the same steps drive both" no longer applies to the reserve and finalize primitives; composition happens at the commit() level instead.

Links

  • Refines ADR-0002: keeps the two-phase saga and its drivers for multi-transfer workflows, replaces the single-commit driver.
  • Depends on ADR-0003: the write-ahead record, phase tracking, and roll-forward recovery the function relies on.