The previous articles built up a picture of what a compiler can verify about individual operations: their outcomes, what data they can see, what state they require. The operations article introduced N-track pipelines where every outcome gets handled and errors can accumulate along the way. If every step is a pure transformation, that model is sufficient — errors collect, the caller handles them, nothing external has changed.
But real procedures aren't pure transformations. An away mission involves assembling the team, briefing them, beaming them down, establishing contact, collecting samples, and beaming them back. Each step commits real effects — crew assignments change, transporter logs update, communication channels open. When step four fails, steps one through three have already happened. Accumulated errors tell you what went wrong. They don't undo what already happened.
I explored error recovery for effectful pipelines — steps that commit external state, not just transform data. The answer was compensation: each forward step paired, when possible, with the action that reverses or settles it. Ruuk's saga keyword makes that pairing a first-class part of the declaration.
The Problem: Distributed Commitment
The challenge isn't failure itself. The challenge is partial failure after partial commitment.
A pipeline handles failure cleanly when no step has committed external state. If step three returns a failure outcome, steps one and two produced data that's still local — nothing external has changed. The failure is just an outcome, not a cleanup problem.
But starship operations commit to external systems. Beaming a team down changes their location in the ship's records. Opening a communication channel allocates subspace bandwidth. Logging samples in the science database creates records other systems depend on. These aren't local transformations — they're effects that persist whether the procedure succeeds or not.
When establishContact fails after beamDownTeam succeeded, the team is on the planet surface with no confirmed communication link. The correct response is to beam them back up — but that compensation logic lives in a catch block somewhere, and whether it matches the current beam-down procedure depends on whether someone updated both when the procedure changed.
The standard approach: execute each step, check the result, compensate on failure.
async function conductAwayMission(mission, team, planet) {
const assigned = await assembleTeam(team, mission);
if (!assigned.ok) return missionFailed(assigned.error);
const briefed = await briefTeam(assigned.team, mission);
if (!briefed.ok) {
await releaseTeam(assigned.team);
return missionFailed(briefed.error);
}
const landed = await beamDown(briefed.team, planet);
if (!landed.ok) {
await debriefTeam(briefed.team);
await releaseTeam(assigned.team);
return missionFailed(landed.error);
}
const contact = await establishContact(landed.team, planet);
if (!contact.ok) {
await beamUp(landed.team);
await debriefTeam(briefed.team);
await releaseTeam(assigned.team);
return missionFailed(contact.error);
}
// ... remaining steps
}
This is competent, readable code. It handles each failure and compensates correctly. But the compensation list grows with every step — each failure handler must repeat every previous compensation in reverse order. Add a step between briefTeam and beamDown and every subsequent handler needs updating. The relationship between a forward step and its compensation is implicit: releaseTeam undoes assembleTeam, but you learn that by reading the error handler, not the step declaration. And nothing verifies that every effectful step has a corresponding undo.
Sagas as Declarations
A saga declares steps in order; each step that commits external state declares its compensating operation:
pub saga ConductAwayMission =
subject mission: Mission<Approved>
payload team: List<CrewMember>
payload planet: Planet
step assembleTeam
compensate releaseTeam
step briefTeam
compensate debriefTeam
step launchMission
compensate abortMission
performs Mission.Approved -> Mission.InProgress
step beamDown
compensate beamUp
step establishContact
step collectSamples
step beamUp
step completeMission
performs Mission.InProgress -> Mission.Completed
outcomes =
| MissionComplete of Mission<Completed>
| ContactFailed of reason: String
| TransporterFailure of TransporterError
| TeamUnavailable of missing: List<String>
Read it as a mission briefing: assemble the team (if it fails later, release them), brief them (debrief if needed), launch the mission, beam them down (beam them back up if something goes wrong), establish contact, collect samples, beam up, and complete the mission with a state transition from InProgress to Completed.
establishContact and collectSamples have no compensate clause. establishContact is a read/verify step — if it fails, there's nothing to undo beyond the earlier compensations. collectSamples is the forward payload of the mission — if sample collection fails, the earlier steps still need unwinding, but "uncollecting" samples isn't a meaningful operation.
The final beamUp step (the planned return, not the compensation) and completeMission also lack compensation — by the time you reach them, the mission has substantively succeeded. completeMission performs the typestate transition from article 5, moving the mission from InProgress to Completed.
The saga's outcomes block declares the saga-level results — the outcomes a caller must handle. When a step fails, the saga unwinds completed steps that declared compensation and surfaces the failure as the appropriate saga outcome.
Automatic Compensation on Failure
If establishContact fails after beamDown, launchMission, briefTeam, and assembleTeam have all succeeded, the saga unwinds automatically in reverse order:
-
beamUp— compensatesbeamDown(team is returned to the ship) -
abortMission— compensateslaunchMission(mission is settled as aborted) -
debriefTeam— compensatesbriefTeam(team status is reset) -
releaseTeam— compensatesassembleTeam(crew assignments are freed)
Last completed, first compensated. The compensation order is the reverse of the execution order — the same principle as unwinding a call stack, but applied to domain operations with real-world effects.
The developer doesn't write this unwind logic. The saga declaration defines it. The compensation for beamDown is beamUp, declared on the same line. The relationship between forward step and undo is visible, explicit, and maintained in one place.
What the Compiler Verifies
Compensation completeness. Every step that calls a mutating operation should declare a compensating action. The compiler warns on steps that modify external state without a compensate clause — not an error, because some mutations genuinely can't be undone (you can't un-send a transmission), but a signal that the developer should make that judgment explicitly.
Operation existence. Each operation named in step and compensate must exist in scope. You can't reference a compensation operation that hasn't been defined.
Performs consistency. If a saga step uses performs, the same validation rules from the typestate article apply: the subject parameter must match the source state, the success outcome must match the target state, no mismatched transitions.
Order guarantees. Compensation executes in reverse execution order by definition. The saga declaration makes this explicit — reading the steps top to bottom tells you the compensation order bottom to top. That leaves less runtime coordination logic to get wrong.
An agent generating a saga gets the same guardrails. The compiler warns on uncompensated mutations whether a human or an agent wrote the declaration — the structural check doesn't depend on who authored the code.
A Richer Example: Ship Repair Workflow
Away missions are dramatic but linear. Ship repairs show how sagas handle more complex workflows with multiple external system interactions:
pub saga RepairCriticalSystem =
payload system: ShipSystem
payload damage: DamageReport
by chief: CrewMember
step assessDamage
step allocateRepairTeam
compensate releaseRepairTeam
step requisitionParts from supplyStore
compensate returnParts to supplyStore
step takeSectionOffline
compensate bringBackOnline
step performRepair
step runDiagnostic
step certifyRepair
performs RepairOrder.InProgress -> RepairOrder.Completed
outcomes =
| Repaired of RepairOrder<Completed>
| PartsUnavailable of needed: List<PartId>
| DiagnosticFailed of failures: List<String>
| SectionCritical of reason: String
Each forward step reads naturally with its compensation: allocate a team / release the team, requisition parts / return the parts, take the section offline / bring it back online. The parameter roles from article 3 appear in the steps — requisitionParts from supplyStore — making the saga declaration read like a procedure manual.
If runDiagnostic fails after the repair is done, the saga unwinds: bring the section back online, return the parts, release the team. The repair itself may need manual intervention — there's no compensate on performRepair because "un-repairing" isn't a meaningful action. That's a deliberate design choice, visible in the declaration.
Sagas vs. Pipelines
Both sagas and pipelines compose sequential operations. The distinction matters:
A pipeline passes data forward. Each step transforms the previous step's result. N-track pipelines can accumulate multiple errors along the way, but the model assumes no step has committed external state — failures are outcomes to report, not effects to undo.
A saga coordinates effects. Each step may commit external state — a transport, a database write, a resource allocation. If a later step fails, committed state must be undone. The saga manages the compensation stack.
Use a pipeline when steps are pure transformations or when a single system handles rollback automatically (a database transaction). Use a saga when you're coordinating across multiple systems that don't share a transaction boundary — a transporter system, a personnel database, a supply chain, a communication array.
What Sagas Complete
The previous article ended with "What Compounds" — each feature was designed for practical relief and arrived at structural correctness. Sagas extend the pattern one more time. I was exploring error recovery in effectful pipelines, and what started as a way to declare compensation alongside forward steps turned into compiler-verified workflow integrity.
With scattered compensation logic, the answer to "what happens if the transporter fails mid-mission?" is: "our error handlers call the right cleanup functions." That answer depends on every handler being correct, complete, and synchronized with the forward path. With a saga declaration, the answer is the declaration itself: beamDown has compensate beamUp. When someone adds a step, the syntax prompts the question "does this step need compensation?" — and the compiler warns if a mutating step omits one. The forward path and the compensation path live together, so they evolve together.
Series Conclusion
This series has built up a picture of what a compiler could verify about the code agents write and humans review.
Operations and outcomes (article 3) give the compiler visibility into what an operation means — not just its return type, but its domain-specific results. The compiler holds every caller to every outcome.
Projections (article 4) give the compiler control over what each operation can see. Data access boundaries are structural properties of the type, not runtime filters maintained separately.
Typestate (article 5) gives the compiler control over when operations can run. State preconditions are in the type system, not in runtime guards. Invalid transitions are compile errors.
Sagas (this article) give the compiler visibility into multi-step workflows and their compensation. The forward path and the undo path are declared together, maintained together, and verified together.
Each feature stands on its own, but the compounding is the point. An operation with typed outcomes, scoped to a projected data view, guarded by typestate, coordinated within a saga — that's a level of structural verification that testing and code review alone don't reliably achieve. The compiler holds invariants that humans struggle to keep in working memory. In an era where agents write code at volume and humans review it under time pressure, that starts to look less like a nice-to-have and more like part of the architecture of trust.
Ruuk is in alpha. The syntax will continue to evolve and the implementation has a long road ahead. But the design criteria — compiler-visible domain semantics, structural enforcement over behavioral convention, progressive development that doesn't sacrifice rigor — are the properties I think the agentic era makes newly important. If these ideas resonate, give ruuk a spin; follow along on GitHub and weigh in on the discussions. The best languages get shaped by the people who care about the problems they solve.
This article was created with the help of AI
Top comments (0)