When apply fails

A plan can fail half-way through. What the engine does next is the part worth knowing before you need it.

atlantide apply --on-failure rollback   # default: undo what this run completed
atlantide apply --on-failure halt       # stop where it stands, undo nothing

The three phases

Apply runs in phases, and only the first one is reversible.

Phase What runs Order Reversible
1 CREATE, UPDATE, REPLACE dependencies first, in parallel yes
1b the destroy half of a create-before-destroy REPLACE dependents first no — terminal
2 DELETE dependents first no — terminal

Phases 1b and 2 are terminal by design: recreating a destroyed resource would lose its identity and its outputs, so a failure there is reported rather than compensated. Committed stack outputs are persisted only after all three phases succeed.

The compensation saga

Under the default --on-failure rollback, every node that completes in phase 1 records an undo. On failure they run in reverse completion order — not reverse graph order, because what matters is what this run actually did.

What was applied What the undo does
CREATE provider.delete, then drop the state row. The resource carries the outputs of its create — notably its id — so the delete acts on that resource directly rather than locating it by attributes, which could match an unrelated resource sharing them.
UPDATE provider.update back to the prior values, then restore the prior state row verbatim, still sealed.
REPLACE Undo the create half, then restore the prior resource and its recorded outputs.

The saga catches BaseException, not Exception: an interrupt arrives as a cancellation, and a narrower clause would skip compensation on a Ctrl-C part-way through an apply that has already created resources.

When rollback deliberately does not run

A run that lost its lease is not rolled back. A compensation is a provider call and a state write, and a run that no longer holds the lock may be racing whoever took it — undoing "its" creates could destroy theirs. Leaving the resources in place is the lesser harm. The report says so explicitly rather than staying quiet about it.

This is the one blocker today. Recover with atlantide refresh.

When rollback itself fails

An undo is a provider call followed by a state write, so a failure between the two leaves state describing a resource that is no longer there. Because the diff is symbolic, that row's stored hash would still match config — and the next plan would report NOOP, hiding the divergence permanently.

The engine forecloses that. Before running a node's compensation it poisons the row, clearing its input_hash so the next plan cannot Merkle-skip it. The undo's own state write clears the poison when it succeeds, so being early costs nothing, while poisoning afterwards would only survive an exception — a process killed mid-rollback would leave the row untouched and the divergence silent.

When compensations fail, the original error and the rollback errors are raised together, as a group, so every leaf is rendered. The apply failure is never replaced by the rollback failure.

Interrupts

The first Ctrl-C cancels the run and follows the failure path above: under the default, completed nodes are compensated on the way out. It exits 130.

A second Ctrl-C abandons that cleanup. The compensation is shielded against it, so the press cannot cancel an undo between its provider call and its state write — the exact window poisoning exists to make visible — but the CLI's hard exit still wins. State may then no longer describe what exists: run atlantide refresh before applying again.

The run's lock is deliberately not released on that second press. An abandoned run may still have provider calls in flight, so the lease is left to lapse, or to be cleared explicitly with atlantide state unlock, rather than handed straight to a second writer.

The re-diff before anything runs

Apply does not execute the changeset that plan printed. Once it holds the lease it re-diffs against freshly loaded state — it must, or a resource another run created in the meantime would still be planned as a CREATE and get built twice.

So the plan a human approved and the plan about to execute can legitimately differ, and the gap between them is exactly where an unreviewed destroy fits. When they differ, the run stops with a PlanDriftError naming what appeared and what disappeared, rather than reconciling it silently.

After a failed run

atlantide refresh          # what actually exists, vs what state records
atlantide state list       # what state records
atlantide state unlock     # who holds the lease

refresh is the recovery command in every case above. It is read-only unless given --write, and a resource the provider cannot find is reported but kept unless --prune is also given.

See also Troubleshooting for the specific errors these paths raise.