Skip to content
5 min read · 914 words

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

typescript
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 reviewable and in executable (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
  • createPlan rejecting duplicate identifiers
  • clonePlan atomically generating an editable, unapproved, run-free plan bearing parent provenance
  • claimRun succeeding exactly once under concurrency — two simultaneous workers race to claim; one gets ok, the loser gets run_already_claimed carrying the winner's runId
  • claimRun admitting re-entry after aborted and after halted, and rejecting claims after completed
  • round-tripped frontier_snapshot and edge_taken entries preserving artifact HANDLES — the restored execution frame returns with an artifacts table 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.