---
url: 'https://adk.nht.io/batteries/orchestration/lifecycle.md'
description: >-
  Three states, and the permission gate that IS the transition between two of
  them — so 'approved' and 'executable' cannot disagree.
---

# The Lifecycle

## LLM summary — orchestration lifecycle

* States: `editable`, `reviewable`, `executable`. Legal transitions: `editable→reviewable` (freeze), `reviewable→editable` (unfreeze, free), `reviewable→executable` (THE GATE, carries `ApprovalRecord`). No others.
* `freezePlan(planId, inputs?)` validates the folded graph and calls `transition` only if no issue is `blocking`, passing the folded digest as `expectedDigest`.
* `approvePlan(planId, record)` recomputes the reachable authority set and asserts SET-EQUALITY with `record.authoritySet` before touching the store.
* Ops are refused unless the plan is `editable` (`AppendResult.reason: 'not_editable'`).
* **One plan id, at most one run, EVER.** Enforced by the durable `claimRun`, not by the optional lock.
* Run outcomes (`completed`/`halted`/`aborted`) are outcomes of the RUN, not lifecycle states — the plan stays `executable`. `aborted`/`halted` are resumable via `resumeRunId`; `completed` is not.
* Recovery from a halting failure is `clonePlan` → a cold `editable` plan with parent provenance.

```mermaid
stateDiagram-v2
  editable --> reviewable: freezePlan
  reviewable --> editable: unfreeze
  reviewable --> executable: approvePlan — THE GATE
```

Edits are refused in `executable`. The only way back is `unfreeze`, which lands the plan in `editable` — and the only route out of `editable` runs through the gate again.

Three states. You mutate a plan in `editable`, inspect it in `reviewable`, and execute it in `executable`. That is the entire topology. Every illegal transition triggers an explicit, model-addressed error rather than a silent no-op: mutating an `executable` plan, freezing an `editable` plan that fails submit checks, gating an `editable` plan directly, or attempting to run one that is merely `reviewable`.

## The gate is the transition

The permission gate **is** the `reviewable → executable` transition. This is the single most load-bearing decision in the lifecycle, and it exists because boolean flags are a broken way to model authorization.

The prevailing pattern in software is treating approval as a boolean column on a mutable record. That immediately manufactures two independent facts: "this plan was approved" and "this plan may run." Once you have two facts, you need synchronization code to keep them from contradicting each other. Every execution pathway must remember to check the flag. Every mutation pathway must remember to clear it. When someone inevitably adds a code path that forgets either check, unapproved or modified plans execute in production.

Collapsing the gate into the state transition eliminates the failure mode by construction. There is no flag to inspect. A plan that is not in `executable` cannot run because the engine refuses to touch it:

```typescript
// A frozen but ungated plan simply refuses to run.
await orchestration.executePlan('my-plan', options)
// throws: cannot run plan "my-plan": not_executable
```

Re-gating is equally mechanical. You cannot edit a plan while it sits in `reviewable` or `executable`; you must unfreeze it back to `editable`. From `editable`, every legal route back to `executable` must cross the gate again. No defensive application logic has to track mutations or clear cached permissions; the state machine closes the forgot-to-check failure mode outright.

## Freezing

`freezePlan` folds the op log, runs every submit-time check against the folded graph, and invokes the store's `transition` **only** when zero issues are `blocking`:

```typescript
const { ok, issues } = await orchestration.freezePlan('my-plan')
if (!ok) {
  // Every issue names the node and the fix — these are read by a model, not just logged.
  for (const issue of issues) console.log(issue.code, issue.message)
}
```

The cryptographic content digest is what makes this commit race-free. Validation evaluates the plan at digest D. The store commits the transition only if the plan is *still* at digest D when the write lands. If a concurrent edit appends an op while validation is running, the digest changes and the transition aborts. An edit cannot slip past an already-passed check.

Issues carry one of two severities. A `blocking` issue halts the freeze outright; an advisory issue is surfaced to the caller and permits the transition. A duplicate edge ID, for example, is advisory during the op-log fold—rejecting it there would make graph reduction order-dependent—but becomes strictly blocking at freeze.

## One plan, one run, ever

A plan ID admits **at most one run across its entire lifetime**. Not "one run at a time"—one run, ever. That invariant is enforced by `claimRun` in the durable store, never by an advisory distributed lock. Deployments can omit the lock; the guarantee cannot rest on optional infrastructure.

Run outcomes belong to the *run*, not to the plan lifecycle. The plan remains in `executable`, and the run fold reports what happened:

| Outcome | Meaning | Resumable |
|---|---|---|
| `completed` | Ran to the end. | No — it is finished. |
| `aborted` | Stopped short (turn abort, unhandled node failure). | **Yes**, via `resumeRunId`. |
| `halted` | Cannot proceed (an unsatisfiable join, say). | **Yes**, once the cause is addressed. |

`aborted` and `halted` are **pauses, not conclusions**. Resumption occurs entirely within `executable`, restarting against the exact same content digest. Because the digest cannot mutate beneath an interrupted run, the persisted execution frontier remains trustworthy.

```typescript
const interrupted = await orchestration.executePlan('my-plan', { input, signal })
// interrupted.outcome === 'aborted', interruption.kind === 'turn_abort'
// interrupted.frontier.frames tells you where it stopped.

const finished = await orchestration.executePlan('my-plan', {
  input,
  resumeRunId: interrupted.runId,   // re-enter the SAME run
  invokeCall,
})
```

## Recovery is a clone

A halting failure does not reopen the plan for editing. Allowing someone to edit a partially executed plan would invalidate the approval the run operated under while mutating history after the fact. Recovery is `clonePlan`. It produces a fresh `editable`, unapproved, completely **cold** plan that carries durable provenance linking it to its parent:

```typescript
await orchestration.store.clonePlan('my-plan', 'my-plan-v2')
// v2 is editable, unapproved, and has no run of its own.
```

When rendered to prose for operator review, the clone explicitly warns that executing it will repeat side effects the parent already committed, listing each completed node by name. That friction is deliberate: a clone is not an edit. It is an entirely distinct plan that happens to share history with one that already touched the outside world.
