Baseleg Docs

ADR-007: Atomic writes and compensation for cross-aggregate transactions

Date: 2026-08-17 Status: Accepted

Context

checkoutBooking and returnBooking (packages/application/scheduling) each perform a real, persisted write against the Aircraft aggregate — aircraftDispatcher.dispatch()/.release()before persisting the corresponding Flight/Booking writes, with no rollback or compensation if the latter fails:

  • checkoutBooking.ts: aircraftDispatcher.dispatch() persists before flightRepo.save()/bookingRepo.update().
  • returnBooking.ts: aircraftDispatcher.release() persists before flightRepo.update()/bookingRepo.update().

A transient failure between the two leaves the Aircraft in a state (in_use/available) that doesn’t match reality, and — critically — makes the affected Booking unrecoverable through the normal use case: a retry re-runs the Aircraft-side precondition check against the already-mutated Aircraft and fails with an unrelated-looking error (aircraft_already_in_use/aircraft_not_in_use) that masks what actually happened. Flagged independently in three prior code reviews (issues #18/#21, #21, #30) before being tracked as issue #31.

Cloudflare D1’s real transaction/batch capabilities were verified empirically (via the D1 test harness from issue #33) rather than assumed:

  • db.batch([...statements]) (available on the shared drizzle db client every repository in packages/infrastructure/repositories is constructed from) is genuinely atomic for real SQL failures — confirmed by batching a valid insert with a unique-constraint-violating insert and observing zero rows persisted afterward.
  • Batch has no cross-statement conditional logic — every statement executes independently. A conditional UPDATE ... WHERE usage_status = ? matching zero rows is not a batch error; the batch still “succeeds,” and any other statements in it still commit. This rules out combining issue #40’s conditional Aircraft update (AircraftRepositoryD1.updateIfUsageStatus) with unconditional Flight/Booking writes in one batch — doing so would silently reintroduce a variant of #40’s concurrency race.

Decision

A hybrid of atomic-write-where-possible and compensation-for-the-rest:

  1. aircraftDispatcher.dispatch()/.release() is left exactly as-is — already a single, already-guarded (issue #40), already-committed write, not touched by this decision.
  2. Flight + Booking writes are combined into a single atomic D1 batch via a new Scheduling-owned port, FlightBookingWriter (saveFlightAndUpdateBooking for checkout, updateFlightAndBooking for return), implemented as FlightBookingWriterD1 using db.batch(). This is a genuine single atomic write for that pair, per the empirical finding above.
  3. If the write fails, it’s first verified, not assumed lost: flightRepo.findById(...) is re-checked before compensating, because a write can report failure while having genuinely committed (e.g. the confirmation was lost after D1 processed the batch). Compensating a write that actually landed would create a worse inconsistency than not compensating at all — the Aircraft reverted while a real Flight/Booking already reflects the opposite state, letting a second booking dispatch the same airframe. If the write did land, the use case returns success. A thrown error from the write call (rather than a returned Result) is treated the same as a returned error, so it can’t skip this path and propagate uncaught.
  4. If the write genuinely didn’t land, the Aircraft write is automatically compensated via AircraftDispatcher.compensateUsageStatus(aircraftId, revertTo, expectedCurrentStatus) — a narrow port method added specifically for this (see below), not a plain dispatch()/release() call.
  5. If the compensating call also fails (a second, independent failure), the use case returns a distinctly-labeled partial_failure_needs_recovery error instead of letting a later retry hit a confusing, unrelated-looking aircraft_already_in_use/aircraft_not_in_use.

Compensation needed its own port method, not a plain dispatch()/release() call. The first implementation of this decision reused the existing dispatch()/release() methods for compensation (calling them with no optional params, on the theory that this would leave meter readings/grounding untouched). That was wrong in one real case, caught in PR review before merge: release() grounds the aircraft when a defect is reported, and dispatch()’s own domain guard (canBeBooked) rejects a grounded aircraft — so compensating a defect-reported return’s write failure via dispatch() would always fail, degrading every such failure straight to partial_failure_needs_recovery regardless of whether recovery was actually needed. Fixed by adding AircraftDispatcher.compensateUsageStatus(): it reuses issue #40’s conditional-update mechanism directly (so a genuine concurrent change is still guarded against via expectedCurrentStatus), but deliberately bypasses dispatchAircraft()/releaseAircraft()’s own domain guards entirely — a compensation isn’t a new dispatch/release request subject to those business rules, it’s putting the flag back the way it was.

What compensation deliberately does not revert: meter readings (currentHobbs/ currentTach) and grounding (from a reported defect) are untouched by compensateUsageStatus — it only ever changes usageStatus. This is intentional — a recorded meter reading or a reported defect is a real-world fact independent of whether the Flight/Booking bookkeeping transaction completes. Reverting them would be actively wrong (un-grounding an aircraft with a genuine reported defect, or erasing a meter value the pilot actually read).

UsageEventRecorder (returnBooking’s post-write Billing event) is explicitly out of scope — a different bounded context (Billing) with its own domain validation (recordUsageEvent); folding it into this Scheduling-owned batch would require the port to know about Billing shapes. Left as a distinct, smaller follow-up if it needs the same treatment.

AircraftDispatcherD1’s own concurrency-guard mechanism (issue #40) is untouched by this decision — different failure mode (concurrent access vs. partial-failure ordering), tracked and fixed separately.

Consequences

  • checkoutBooking/returnBooking gain a flightBookingWriter: FlightBookingWriter parameter.
  • CheckoutBookingError/ReturnBookingError gain two new variants: flight_booking_write_failed (the batch failed, compensation succeeded — safe to retry) and partial_failure_needs_recovery (compensation also failed — the Aircraft is left in a state requiring manual/automated recovery; not currently backed by an automated reconciliation process).
  • A retry after a flight_booking_write_failed result succeeds cleanly, since compensation already restored the Aircraft’s prior usageStatus before that error was returned.
  • No schema change. AircraftDispatcher gains one new port method, compensateUsageStatus(aircraftId, revertTo, expectedCurrentStatus), used exclusively for this compensation — dispatch()/release()’s own signatures and guards are unchanged.

Alternatives considered

  • Single D1 batch spanning Aircraft + Flight + Booking. Rejected — the Aircraft write’s conditional nature (issue #40) is incompatible with unconditional batch-mates; see Context.
  • Compensating action for the full three-way gap, without narrowing anything via batch. Works, but leaves the widest failure window (any of three independent writes can fail). Rejected in favor of shrinking the window with a real atomic batch where D1 allows it.
  • Reconciliation sweep as the primary mechanism. Not needed — the hybrid above closes the gap for the overwhelmingly common single-failure case; building a general reconciliation process for the rare residual double-failure case is exactly the kind of general saga/outbox framework this decision’s scope (issues #18/#21/#30’s two confirmed call sites only) deliberately excludes. The distinctly-labeled error is the honest, minimal substitute.