Skip to content

Ops as teammates

Synthesis is the easy half. Every IaC toolchain produces an artifact. The hard half is what happens after: the gated, multi-step, cross-system apply, and the rollback when step seven of nine fails. The others weld a fixed lifecycle to their engine and still leave that orchestration to shell scripts, CI YAML, and the operator’s memory. chant keeps synthesis pure and holds no opinion of its own, then lets you declare the orchestration as code in *.op.ts files.

An op is a convergent verb. Running it twice is the same as running it once, because every step re-observes and re-derives what it is about to do. That one property is what decides where an op’s durability has to live, and it is why chant does not host a workflow engine.

Ask what an apply needs to survive and four different answers come back. They are not one feature, and they do not live in one place.

The first is the outcome, and it is the op itself. The apply has to reach a declared state and stay there; a convergent verb gets you there by re-running, so a run that dies halfway is answered by running it again rather than by resuming it in place. There is nothing to check-point because there is no mid-sequence position worth remembering. The other three are the subject of this page.

Something has to be running when the op runs, with a checkout and the credentials the work needs. A laptop is a fine machine for a change somebody is watching, and a bad one for anything that has to happen while nobody is. chant run <op> --on fountain hands the run to a steward, one agent on a persistent sandbox, which is a machine an environment is operated from rather than a machine somebody happens to be sitting at.

Some ops run because a clock said so. That is schedule on the op, plain data on the declaration:

export const { op: prodWatch } = WatchOp({
name: "prod-watch",
env: "prod",
schedule: "*/15 * * * *",
});

Three readers render that same field. The github generator turns it into a workflow schedule: trigger. chant operator ticks it from a loop on your own machine. A fountain Steward turns it into a Schedule on the teammate. The declaration does not change when you change your mind about which.

The fourth is somebody saying yes, and it is the one that used to drag a whole engine in behind it. A workflow engine’s gate is a suspended workflow waiting for a signal. That works, and it costs a live workflow plus a worker willing to hold it open for as long as the decision takes.

chant makes the same gate a pair of records on the gate ledger. A run that reaches an unapproved gate reads the ledger. Finding no resolution newer than the pending fact, it writes that fact if there is not one standing already, and exits 3. Nothing is suspended, so nothing has to survive being suspended.

Terminal window
chant run prod-apply # exits 3, pending on rollout-gate
chant approve prod-apply rollout-gate --actor alex # records the resolution
chant run prod-apply # reads it, walks through, applies

Exit 3 is deliberately not 1. A gate is a decision nobody has made yet, so a CI job that reads 3 reports “waiting on approval” instead of paging whoever is on call. Ordering is what makes it safe: a resolution counts only if it is newer than the pending fact it answers, so approving in advance does not clear a gate recorded since.

This is why a steward can be one computer rather than one per pending decision. Nothing sits blocked inside a sandbox holding a machine open, which is exactly what a durable wait would have cost.

Once an op can run in two places, the question is which of them remembers what. chant answers it with a line rather than a merge.

FactStoreRead it with
A run: its steps, its stdout, when it started and how it endedthe runtime that hosted it — the local ledger record, or fountain’s conversationchant run status|log|list, with --on for a hosted runtime
A gate’s resolution, and anything that has to survive across runschant’s ledger branch in gitchant approve, chant run approve <op> <gate>
The declared shape of the estatethe repochant build, chant lifecycle diff --live

A run belongs to the runtime that ran it; a fact about the world belongs to git, where it outlives every runtime that reads it. That split is what lets the same op run locally on Tuesday and on a steward on Wednesday without either of them losing track of an approval the other recorded.

Lead with the local runtime. It needs nothing installed, it runs the whole phase grammar including gates and compensation, and it is the same executor a steward runs inside its sandbox. Move an op to --on fountain when it needs a machine that is not yours, a thread somebody else can read, or a cadence that fires while you are asleep. The declaration is the same either way.