Skip to content

The step engine

Every mutating command is an Operation: an id, an ordered list of steps, and a journal record. The engine owns ordering, journaling and compensation, so an operation is a plan rather than a procedure.

Four functions

Check      func(ctx, *State) (done bool, err error)  // is it already done?
Execute    func(ctx, *State) error                   // do it
Verify     func(ctx, *State) error                   // did it take effect?
Compensate func(ctx, *State) error                   // undo it

Only Execute is required. The other three each buy something specific.

Check is what makes apply idempotent. A satisfied postcondition marks the step skipped, so converging an already-converged system runs nothing and says so. Idempotence built this way is a property of each step rather than a claim about the whole.

Verify is separate from Execute because a tool exiting zero is not the same claim as the system being in the desired state. docker compose up returning 0 says the command succeeded; it does not say the services are healthy. Two questions, two functions.

Compensate undoes. Not every step has one, and that is fine — most steps without a compensator are simply read-only.

What happens when a step fails

flowchart TD
    S[step fails] --> Q{RequiresIntervention?}
    Q -->|yes| M[requires-manual-intervention<br/><small>exit 12, keeps surfacing</small>]
    Q -->|no| P{FailurePolicy}
    P -->|Abort| A[stop, nothing mutated yet]
    P -->|Continue| C[record and carry on]
    P -->|Compensate| R[undo completed steps, newest first]
    R --> D[compensated<br/><small>exit 11, system where it started</small>]

Compensation runs newest-first and includes the step that failed — it may have mutated before failing, which is exactly the case a Verify failure describes.

The distinction that matters most

Three outcomes that a lesser design would collapse into "error":

Status Exit What it means
failed varies The system is where it started.
compensated 11 Partial work was successfully undone.
requires-manual-intervention 12 It could not be, and a human has to look.

Collapsing these would hide exactly the case that needs a person. Exit 12 keeps surfacing in status and doctor until an operator clears it explicitly, and the systemd unit sets RestartPreventExitStatus=12 so a machine that needs a human stops instead of looping.

Intervention is declared, not inferred

RequiresInterventionOnFailure is an explicit field on a step. Inferring it from a missing compensator was the obvious shortcut and would have been wrong: most steps without one are read-only, so every failed health check would demand human acknowledgement. That trains people to clear the flag without looking, which destroys the value of the one signal meant to stop them.

Migrations and restores declare it. Health checks do not.

The journal

Every transition is written before and after the work:

{"id":"op_01KZ…","type":"update","status":"running","steps":[]}

Before, so a crash mid-step leaves a record saying which step was in flight. After, so a completed step is known to be complete. That is what makes --resume possible: the journal says where to pick up, rather than the operation guessing.

It is append-only and the ids are ULIDs — lexicographically sortable and timestamp-prefixed — so reading the file in order is reading the history in order.

Reuse rather than duplication

update is four steps of its own followed by apply's eleven:

flowchart LR
    subgraph update
      V[verify] --> C[compatibility] --> B[backup] --> S[stage]
    end
    S --> A["apply's pipeline<br/><small>preflight → … → smoke test</small>"]

Duplicating those eleven would mean two lists to keep in agreement, and the second one would be the one nobody tests. rollback does the same thing from the other direction: swap the pointer, then converge with the same pipeline.

Dry runs

--dry-run runs the plan without any Execute. It reports the step list, marks what Check says is already done, and renders a configuration diff.

It deliberately does not take the deployment lock: planning mutates nothing, and making --dry-run wait on a running update would defeat the point of being able to inspect a plan while one is in flight.