The Store Contract
PlanStore is the single seam you must implement for durable persistence. InMemoryPlanStore is the reference implementation: mathematically correct, and completely useless for production for the obvious reason that memory disappears when the process exits.
The split: the battery validates, the store commits
A store does not evaluate policy. It could not even if you wanted it to. Deciding "is an evaluator wired", "is this tool on the tier-C allowlist", or "does this reference taint a call arg" requires orchestrator domain knowledge that an external storage adapter cannot see. Shoving policy checks down into storage would force every backend implementor to reimplement the battery's validator from scratch.
So transition is deliberately narrow. It proves the plan is in the expected state at expectedDigest, checks that the target state is a legal successor, and applies it — persisting the ApprovalRecord in the same commit during a reviewable → executable move. It returns the losing outcome rather than throwing exceptions: callers that lost a race need to inspect what actually happened, not unpack an error trace.
The digest is what prevents concurrent corruption: the battery validates the plan's contents against digest D, and the store commits if and only if the plan remains at D.
Two things that are easy to get subtly wrong
appendRunEvents must commit the batch atomically. The entire commit protocol depends on node_settled, every resulting edge_taken, and the updated frontier_snapshot landing in one indivisible write. A store that persists them across separate statements will eventually be killed between them. When that happens, the event fold reads a settled node whose child edges never made it to disk, corrupting the graph state permanently.
"Settled" means COMPLETED. aborted and halted are interruption points, not finality. The engine's interruption taxonomy classifies an aborted turn as resumable with its frontier preserved at the exact same digest. A store that treats every run_settled event as terminal makes resumeRunId answer run_already_settled for the exact recovery paths it was built to handle. Re-entry must be admitted after aborted or halted, and refused only after completed.
We are about to state an opinion
That second point is not hypothetical advice. The reference store shipped with that exact bug, and the conformance suite failed to catch it because its claim-run test checked only the completed branch — it validated only the half that already worked. A conformance suite that only ever passes is useless theatre; these assertions exist because someone already broke production.
Verify it
import { runPlanStoreConformance } from '@nhtio/adk/batteries/orchestration/conformance'
runPlanStoreConformance(async () => new MyDurablePlanStore(connection))It is a vitest suite (vitest is an optional peer) that rigorously exercises the edges of the contract where naive implementations silently fail:
- an append rejected in
reviewableand inexecutable(not_editable) - the settlement batch committing atomically, verified by killing the write between the two appends
- the stale-approval interleaving — actor A inspects
reviewable@D1, actor B unfreezes, edits, and refreezes to D2, and actor A's approval targeting D1 is rejected createPlanrejecting duplicate identifiersclonePlanatomically generating aneditable, unapproved, run-free plan bearing parent provenanceclaimRunsucceeding exactly once under concurrency — two simultaneous workers race to claim; one getsok, the loser getsrun_already_claimedcarrying the winner'srunIdclaimRunadmitting re-entry afterabortedand afterhalted, and rejecting claims aftercompleted- round-tripped
frontier_snapshotandedge_takenentries preserving artifact HANDLES — the restored execution frame returns with anartifactstable whose reader rebinds and reads successfully readOps({throughRevision})serving a historical prefix and rejecting requests for an unreached revision
That last group is where real durable backends routinely diverge from in-memory mocks, and where storage bugs stay invisible until production load hits.
Artifacts persist as handles
An ArtifactTable attaches to PendingFrame.artifacts, edge_taken.artifacts, and JoinState.arrivals[].artifacts. It persists strictly as encoder handles — structured {tag, locator} pairs, never raw bytes — and rebinds upon resumption via resolveSpoolReader.
We are about to state an opinion
Registering your durable store's reader resolver is load-bearing architecture, not optional hygiene. In-memory and fetch resolvers register automatically; a durable resolver is entirely on you, because only your runtime holds the live connection that a serialized locator cannot carry across processes. Without it, a downstream transform executed after a resume will crash citing an unrecognized tag. It crashes loudly — which is correct — but the fix is wiring your registration at boot, not modifying the plan.
The lock is optional, and honest about it
PlanLockFactory is a BYO integration point used only during execution. If your deployment runs a single executor process, omit it entirely without penalty. Provide one — whether backed by Verrou or any distributed locking mechanism — and concurrent execution races become unlikely rather than routine.
Notice the phrase: unlikely, not impossible. It is a TTL lease without a fencing token. It cannot prevent a partitioned or GC-stalled process from running past lease expiration while a second executor legitimately takes over. Fixing that properly requires a monotonic epoch token issued with the lease, threaded through every persistent write, and verified by a store that rejects stale-epoch writes. Mandating that would complicate every single PlanStore implementation to accommodate distributed consensus. We chose not to lie: this seam is explicitly best-effort. The residual risk is handled where that failure mode belongs: per-node onIndeterminate policies and replaySafe flags.
Plan editing requires no locks at all. Op-log convergence handles concurrent authoring, and submit-time validation is what catches an incoherent result before execution ever begins.