Skip to content

Stores

Run truth lives in the journal, and the journal lives in a store. Everything else Rulvar persists goes through two sibling seams: a TranscriptStore for large blobs, and an optional ModelKnowledgeStore for cross-run model claims. All three contracts are deliberately tiny. The journal and transcript seams treat your data as opaque bytes: neither parses a payload, interprets an entry kind, or derives state; the kernel derives every fact by folding entries, and those stores just keep them. The knowledge store follows its own discipline, a versioned snapshot with compare-and-swap, described below.

SeamHoldsShipped implementations
JournalStoreJournal entries and RunMeta recordsInMemoryStore, JsonlFileStore (@rulvar/core), SqliteStore (@rulvar/store-sqlite), PostgresStore (@rulvar/store-postgres)
TranscriptStoreTranscripts, turn-boundary checkpoints, worktree patches, persisted compiled-workflow sourcesInMemoryTranscriptStore, FileTranscriptStore (@rulvar/core)
ModelKnowledgeStoreEvidence-backed cross-run model claimsFileModelKnowledgeStore (@rulvar/core)

The journal store contract

A journal store is exactly five methods:

ts
interface JournalStore {
  append(runId: string, e: JournalEntry, lease?: Lease): Promise<void>;
  load(runId: string): Promise<JournalEntry[]>;
  putMeta(m: RunMeta): Promise<void>;
  listRuns(f?: RunFilter): Promise<RunMeta[]>;
  delete(runId: string): Promise<void>;
}

That is the whole seam. Suspensions, resolutions, abandoned branches, plan revisions, reuse-by-reference: every mechanism above the kernel is expressed as ordinary appends plus pure folds over the loaded entries, so the storage contract never grows. There is no caller-driven compare-and-swap, no entry mutation, no query language.

The store is dumb by design, and the dumbness is normative. Five obligations define correctness:

ObligationMeaning
AtomicityAn append is all-or-nothing; a reader never observes a torn entry.
Total per-run orderload(runId) returns entries exactly in append order, stable across calls.
Read-your-writesOnce an append promise resolves, an immediate load sees the entry.
Opaque payloadEntries come back byte-equivalent as JSON values; unknown kinds and unknown fields pass through untouched.
Monotonic seqAn append whose seq is not strictly greater than the run's stored tail rejects with the typed JournalOrderViolation and never becomes visible; two entries with the same (runId, seq) can never both persist.

Monotonic seq is the store's one integrity constraint, and it reads a single top-level field of the entry envelope, never the payload. It exists to fence off a second writer racing the same journal from a stale tail (a double resume, a zombie segment): exactly one racer persists and the loser gets the typed conflict instead of silently corrupting replay. In-process it complements the engine's own rule that exactly one live segment owns a run (see Resolving a settled run); cross-process, fencing remains the lease epoch's job.

Opaque payload matters most. Content keys, the replay disposition, and every fold read loaded entries verbatim; a store that normalizes, deduplicates, reorders, or trims fields silently corrupts replay identity. Run metadata is kept apart on purpose: the engine writes RunMeta through putMeta as a separate record, so listRuns never has to parse journal payloads.

Any store satisfying these obligations works, and the executable definition of "satisfying" is @rulvar/store-conformance. If you want to build one, see Writing a store.

Leases and fencing epochs

A plain JournalStore assumes one writing process per run. For queue deployments, where any worker may pick up a suspended run, a store can add the lease capability:

ts
type Lease = { runId: string; owner: string; epoch: number };

interface LeasableStore extends JournalStore {
  acquire(runId: string, owner: string): Promise<Lease>;
  renew(l: Lease): Promise<void>;
  release(l: Lease): Promise<void>;
}

The rules:

  • acquire on a run whose lease is currently held rejects with a typed LeaseHeldError. The error is retryable by contract: try again after the holder releases or the ttl expires.
  • Leases carry a store-configured ttl; the holder must renew at an interval of at most ttl/3.
  • The epoch is a fencing token: monotonic per run, surviving release, expiry, and delete/recreate of the same runId. The store keeps the epoch high-water mark as a tombstone through deletion, so a zombie lease from a deleted incarnation (same runId, same stable owner identity, the Kubernetes StatefulSet norm) can never fence green against the recreated run. An append or renew carrying a stale epoch (an old epoch, a foreign owner, or an expired lease) is rejected, and the rejected entry never becomes visible to a subsequent load.

The fencing epoch is what makes multiple workers safe. Pass the lease to engine.resume(runId, wf, { lease }) and the engine carries it on every journal append of that resume, through the kernel's single append site. A worker that stalls, loses its lease to a timeout, and wakes up later cannot corrupt the journal: its writes carry a stale epoch and bounce. You do not have to trust the zombie to notice it died; the store refuses it. An append carrying no lease is not fenced; it asserts the single-writer precondition instead.

One more check rides the lease path: the hashVersion compatibility scan is repeated at acquire, so a worker running an older library cannot write into a journal that already contains newer entries.

The fenced writes capability

The epoch above fences journal appends. putMeta and delete accept the same optional trailing lease, and a store can promise to enforce it there too by declaring the marker:

ts
interface FencedJournalStore extends JournalStore {
  readonly fencedWrites: true;
}

The promise (the executable definition is fencedWritesConformance in the conformance kit): every mutation carrying a lease verifies it is the current holder FOR THE RUN THE MUTATION TARGETS, atomically with the mutation itself, and rejects with the typed LeaseHeldError leaving nothing changed when it is not; a lease for a different run guards nothing; a mutation carrying no lease keeps single-writer semantics. The engine threads the segment's lease into every meta write and every transcript blob write of a leased resume, so over a declaring store a superseded worker can no longer overwrite the successor's meta row at its late settle (the stranded run finding of the fenced run state RFC), and the queue worker's retention sweep passes its brief lease through engine.deleteRun so a fenced store refuses a deletion from a superseded holder. Note the boot consequence: a stale segment's very first meta write is refused typed, so it dies with zero paid calls instead of paying a live dispatch whose append then bounces.

SqliteStore declares the marker on both sides. The journal store itself enforces it on append, putMeta, and delete, and its transcripts() method returns the transcript-side twin: a TranscriptStore whose blobs live in the same database as the lease rows, which is what makes the capability implementable at all (fencing a blob write atomically needs the blobs and the lease state in one transactional domain). Over the pair, a superseded segment's late checkpoint save is refused typed instead of landing last write wins at the deterministic ref both segments share, so a later boot of the attempt can no longer decode regressed turn state and replay turns the successor already paid for (the checkpoint finding of the RFC). The shipped file and in-memory transcript stores do NOT declare the marker (they are single-writer by contract), so checkpoint blobs stay advisory over those. A host that requires the full fence asserts it at deployment time with assertFencedWrites(engine.stores) (or checks one store with hasFencedWrites), both exported from @rulvar/core:

ts
import { createEngine, assertFencedWrites } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';
import { SqliteStore } from '@rulvar/store-sqlite';

const store = new SqliteStore({ path: './rulvar.db' });
const stores = { journal: store, transcripts: store.transcripts() };
assertFencedWrites(stores); // throws unless BOTH declare fencedWrites
const engine = createEngine({ adapters: [anthropic()], stores });

The multi-process soak

The capability suites above prove each fenced surface in isolation. The soak proves the whole promise under real concurrency: runMultiProcessSoak in the conformance kit spawns writer processes that storm one store location through every fenced surface (journal appends, meta writes, transcript blob puts and deletes, fenced run deletion, renew, release), with stalls injected past the lease ttl so takeovers happen while superseded holders are still alive and probing every surface with their dead leases. Each accepted mutation carries the holder's epoch and a per-tenure counter, so afterwards the referee rebuilds the one serial history fencing requires and diffs it against the actual journal, meta row, and blobs: any stale acceptance, lost accepted write, epoch inversion, or divergent final byte fails the soak. The storm runs until an activity quorum is met (takeover count, per-surface accepted writes, typed stale rejections), so a slower machine storms longer instead of asserting on thin coverage.

Concurrent construction is deliberately part of the exercise: every writer constructs the store bare, at the same moment, over the same fresh location, because a fleet start does exactly that. The soak's first storm found that defect in the reference store (concurrent boots collided in the schema bootstrap and died with a raw SQLITE_BUSY) before it reached the fencing at all; the constructor now retries its idempotent bootstrap under a wall-clock bound. SqliteStore runs the soak in its own test suite; wiring it for your store is shown in Writing a store.

The kill-point suite

The soak proves fencing under contention; the kill-point suite proves RECOVERY under real death. killPointConformance runs a scripted engine workflow in a spawned child process over your store, and the child SIGKILLs itself around one durable write per scenario; the referee then waits out the dead owner's lease, resumes the run over its own store instance, and asserts the engine's documented recovery semantics with exact provider re-pay counts. A worker that runs to completion means the kill point was never reached, and the suite treats that as a violation, never a pass.

The five write points are both brackets of the run's durable life (before a write = the write is lost; after = it is durable and everything past it is lost):

  • the running entry (the provider request): either bracket costs nothing extra; the step re-runs once on resume.
  • the ok terminal (the response with its usage): the before bracket is THE at-least-once window, the one place a step is paid twice, because the provider answered and the acknowledgement died; the after bracket replays for free.
  • the limit terminal (maxToolCalls expiry): before resumes as a dangling redispatch restored from the last transcript boundary, so the re-pay is exactly the turns since that checkpoint, not the whole agent; after leaves an unstamped limit terminal in a never-settled run, the documented second chance, and the resume re-runs the agent live in full.
  • the run settle decision: both brackets resume as a pure replay with zero provider calls; a lost settle is re-appended by the resume segment, a durable one is never duplicated.
  • the meta projection (written strictly after the settle): before is the repairable meta-behind residue and the resume heals it; after is a fully consistent run whose resume changes nothing.

Every scenario additionally asserts exactly one ok run settle in the final journal, a healed ok meta, contiguous journal seqs, and the exact workflow value after recovery. SqliteStore and PostgresStore run the whole table in their own test suites; wiring it for your store is shown in Writing a store.

The meta lookup capability

Point operations (engine.resume, the HTTP status endpoint, CLI resume and inspect, the deterministic planner lookup) need ONE run's metadata, and forcing them through listRuns makes each of them scan the whole catalog. A store can add the exact lookup capability, optional exactly like the lease capability:

ts
interface MetaLookupStore extends JournalStore {
  getMeta(runId: string): Promise<RunMeta | undefined>;
}

A missing run resolves undefined, never a rejection. Callers detect the capability with hasMetaLookup(store) or just go through readRunMeta(store, runId), which uses getMeta when present and falls back to the listRuns scan for stores written before the capability; both are exported from @rulvar/core. All three shipped stores implement it (SqliteStore as a primary key query, JsonlFileStore as a single file read, InMemoryStore as a map hit), and the serialization hook wrapper preserves it, meta being unhooked either way.

Alongside it, RunFilter carries an advisory statuses array (match any; combines with the singular status so a meta matches when either does). The queue worker asks for { statuses: ['running', 'suspended'] } so its poll cost tracks the resumable backlog, not the whole history. Advisory means a store may ignore the field and return a superset, and callers re-check status on what comes back; a conformant store must never DROP a matching meta (the conformance kit checks exactly that, plus getMeta agreement when the capability is present).

RunMeta also records genesis: a token minted at the run's fresh start and preserved verbatim by every resume segment. It is the generation identity that tells a deleteRun-then-recreate of the same explicit runId apart from the original run, which journal length and workflow hash cannot. Stores must round-trip it like every optional meta field.

TranscriptStore: big bytes out of the journal

Agent transcripts, turn-boundary checkpoints, and worktree patches are large. Putting them in journal entries would bloat the run's source of truth, so they live in a sibling blob store and journal entries carry only references (transcriptRef, checkpointRef):

ts
interface TranscriptStore {
  put(ref: string, blob: Bytes): Promise<void>;
  get(ref: string): Promise<Bytes | null>;
  list(runId: string): Promise<string[]>;
  delete(ref: string): Promise<void>; // deleting a missing ref is a no-op
}

This keeps the journal small and diffable while agents still resume mid-loop: with a durable transcript store, the runtime writes a checkpoint of the canonical history at every turn boundary, so a crash or an approval wait continues the agent from the same turn without repaying turns or re-invoking tools. Blob contents are engine-internal; the seam carries opaque bytes, same discipline as the journal.

Refs stay inside the store. Every segment of a ref (and every runId, which prefixes the checkpoint and workflow source refs) must be a safe filename token over [A-Za-z0-9._-], and be neither empty, ., nor ..; the resolved path must stay under the configured directory. FileTranscriptStore enforces this on put, get, list, and delete, and the engine refuses an unsafe runId with a typed ConfigError before its first write. An untrusted ref or run id therefore cannot read, write, or delete a blob outside the root.

Retention is engine-side, never a store obligation. Stores delete single blobs; the engine owns the cascade:

ts
await engine.deleteRun(runId);              // every blob list(runId) returns, then the journal
const removed = await engine.pruneRun(runId); // checkpoint blobs of completed attempts nothing references

pruneRun only touches checkpoints of attempts that finished ok: completed, paid work replays from the journal and never boots its checkpoint again. Parked, cancelled, escalated, and hanging attempts keep theirs, because park/unpark and crash recovery boot from them.

ModelKnowledgeStore: the sibling seam

The model knowledge subsystem keeps evidence-backed claims about models in its own store, with a different write discipline: instead of append-plus-fencing it uses compare-and-swap on a monotonic snapshot version.

ts
interface ModelKnowledgeStore {
  current(): Promise<KnowledgeSnapshot>;
  commit(ops: ClaimOp[], expectedVersion: number): Promise<number>;
}

A commit against a version that is no longer current rejects with a typed KnowledgeCasError; the recovery mirrors the lease discipline: re-read current(), rebase your ops, commit again. Concurrent maintenance writers serialize through CAS rejection rather than locks.

The seam is optional and off by default: an engine without a configured ModelKnowledgeStore writes no knowledge entries at all. And even with one configured, workflow runs receive a current()-only handle; commit is unreachable from the runtime, so a run has no write path into the cross-run medium. The shipped FileModelKnowledgeStore keeps the claim store in a single JSON file, ./rulvar.models.json by default.

Shipped stores

In-memory (tests)

createEngine without a stores block gives you InMemoryStore and an in-memory transcript store. Runs execute normally, budgets and journaling all work, and a kept engine instance can even resume its own runs within the same process, but nothing survives a process exit, so a run can never be resumed from another process; a one-time loud warning makes sure the misconfiguration cannot hide in production logs. This is the right default in one place only: tests, where you want zero filesystem residue.

The JSONL file store

JsonlFileStore is the default durable choice and what the umbrella install path steers you to: it ships in @rulvar/core, comes with @rulvar/rulvar, and is the store the CLI writes by default (a .rulvar directory, overridable with --store). Each run is a plain JSONL journal file plus a meta record under one directory:

ts
import { createEngine, FileTranscriptStore, JsonlFileStore } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';

const engine = createEngine({
  adapters: [anthropic()],
  stores: {
    journal: new JsonlFileStore({ dir: './runs' }),
    transcripts: new FileTranscriptStore({ dir: './runs' }),
  },
});

Because entries are appended as JSON lines in append order, the journal doubles as a human-readable event log: tail -f a live run, git diff two runs, grep for an entry kind. A crash in the middle of an append leaves at most a torn final line, which the store detects and repairs at load, so atomicity holds. The repair honors every crash boundary of the final line, including the two subtle ones (RV701, the eleventh comparison experiment's live reproduction): a crash that persisted every JSON byte of an append but not its \n leaves a parseable unterminated tail, which load serves and the next append first terminates in place rather than gluing onto; and a torn last line that carries complete records ahead of its fragment has those records salvaged, never discarded with the fragment. An entry load has served once can therefore not be un-served by a later repair. FileTranscriptStore keeps blobs as one file per ref beside the journal; pair the two whenever you pair them at all, since a durable journal with in-memory transcripts loses agent checkpoints on crash and cannot resume compiled runs across processes.

JsonlFileStore has no lease capability. It is single-writer by contract: one writing process per store directory.

Synchronous I/O behind async signatures

Both shipped durable stores use synchronous Node primitives under their async signatures: JsonlFileStore reads and writes with node:fs sync calls, and SqliteStore runs on the synchronous node:sqlite driver. Every call blocks the event loop for its duration, which is negligible for point operations (getMeta, an append) and noticeable for large scans (listRuns over tens of thousands of runs, load of a huge journal) inside a server process that must stay responsive. Pass filters so scans stay narrow, keep the catalog pruned with retention, or put a worker process between the store and the request path when the catalog grows large.

@rulvar/store-sqlite

bash
pnpm add @rulvar/store-sqlite

SqliteStore implements both JournalStore and LeasableStore with fencing epochs, on the node:sqlite driver built into Node, so it adds no native build step. It is the reference implementation for community stores: when the store authors guide needs a pattern shown against a real backend, this is the store it points at. The fence check and the mutation it guards (an append's insert, a renew's extension, a release's deletion) commit as one immediate transaction, so a takeover from another process cannot land between the check and the write; a store author porting the pattern to another backend must keep that atomicity (the fenced run state RFC records what went wrong when the reference store itself checked in one statement and mutated in the next).

ts
import { SqliteStore } from '@rulvar/store-sqlite';

const store = new SqliteStore({
  path: './rulvar.db', // or ':memory:' for an in-process store
  ttlMs: 60_000,       // lease ttl; 60000 ms is the default
});

The options are path (a database file, or ':memory:'), ttlMs (lease ttl, default DEFAULT_LEASE_TTL_MS, 60000 ms), and an injectable now clock so lease expiry is testable without wall-clock sleeps. ttlMs must be an integer between 1 and 2147483647 ms, refused as a ConfigError before the database opens: zero or a negative would make every lease born expired (an immediate takeover by a second owner), NaN failed the first acquire with a raw sqlite error, and Infinity never expired. The configured value is exposed as the readonly leaseTtlMs, the optional LeasableStore capability createWorker verifies its own ttl against. transcripts() returns the fenced transcript twin over the same database (one per store, sharing its connection, so it works for ':memory:' too and there is nothing separate to close). Call close() when you are done with the handle.

A queue worker acquires the lease, resumes with it, renews on a timer, and releases when the run settles:

ts
import { createEngine, LeaseHeldError, type Lease } from '@rulvar/core';
import { SqliteStore } from '@rulvar/store-sqlite';
import { anthropic } from '@rulvar/anthropic';
import { review } from './workflows/review.js';

const store = new SqliteStore({ path: './rulvar.db' });
const engine = createEngine({
  adapters: [anthropic()],
  // The transcript twin keeps blobs in the same database, so checkpoint
  // saves ride the same fence as journal appends and meta writes.
  stores: { journal: store, transcripts: store.transcripts() },
});

async function resumeAsWorker(runId: string): Promise<void> {
  let lease: Lease;
  try {
    lease = await store.acquire(runId, `worker-${process.pid}`);
  } catch (error) {
    if (error instanceof LeaseHeldError) return; // another worker owns this run
    throw error;
  }
  const renewer = setInterval(() => void store.renew(lease), 20_000); // at most ttl/3
  try {
    const handle = engine.resume(runId, review, { lease });
    await handle.result;
  } finally {
    clearInterval(renewer);
    await store.release(lease);
  }
}

Every append of that resume carries the lease, so if this worker is presumed dead and another acquires the run, the stale worker's remaining writes are fenced out rather than interleaved.

You only write this loop yourself when your host manages the lifecycle: over a leasable store the engine runs the same protocol by default for every fresh run and resume it was not handed a lease for, so a plain engine.run on SqliteStore already holds, renews, and releases the run's lease.

The package also ships SqliteQuotaLimiter, the cross-process reference implementation of the core QuotaLimiter SPI: engine processes pointing it at one database file (its own file, or the store's) enforce one global provider quota, with admission inside a single BEGIN IMMEDIATE transaction, reservations as rows so reconciliation works from any process, and both tables lazily pruned to two accounting windows. Its options are path, the shared rules (validated by the core's validateQuotaRules, and required to be identical across processes because buckets key on rule content), and an injectable now. What the engine does with a denial, and the rule model itself, is the subject of shared provider quotas.

@rulvar/store-postgres

bash
pnpm add @rulvar/store-postgres

PostgresStore implements the same contract over node-postgres (pg): JournalStore plus LeasableStore with fencing epochs, fencedWrites on both the journal side and the transcripts() twin, and the getMeta/leaseTtlMs capabilities. It is the production reference for deployments where SqliteStore's one-file-per-host boundary ends: worker processes on SEVERAL hosts point at one database and coordinate through the same leases and epochs.

ts
import { PostgresStore } from '@rulvar/store-postgres';

const store = new PostgresStore({
  url: 'postgres://rulvar:[email protected]:5432/rulvar',
  schema: 'rulvar',   // default 'public'; created on boot when missing
  ttlMs: 60_000,      // lease ttl; 60000 ms is the default
  max: 10,            // pool ceiling; the default
});

The options are url (the connection string every coordinating process shares), schema (a plain SQL identifier; a non-public schema is created on first use and doubles as cheap isolation for tests and multi-tenant hosts), ttlMs (validated exactly like the sqlite store, refused typed before any connection opens), max (the pool ceiling), and an injectable now clock. Payloads are stored as opaque TEXT on purpose: jsonb normalizes key order and duplicate keys, and obligation A4 forbids normalization, so jsonb appears only in query-side casts and expression indexes. Call close() when done; it drains the pool.

Where the sqlite store serializes the fence check and the guarded mutation with BEGIN IMMEDIATE, PostgresStore runs every run-scoped mutation inside one transaction that first takes a per-run advisory transaction lock. The unit is the same (a takeover from another process or host cannot land between the check and the write; the loser sees the final rows and rejects typed), but the granularity is per run, so unrelated runs never queue behind each other. The schema bootstrap is lazy, idempotent, and serialized on a schema-scoped advisory lock, so a fleet start over one fresh database boots clean without a busy-retry loop; the multi-process soak and the boot race run against a real postgres in CI.

Operational notes:

  • Clocks. Lease expiry uses the CLIENT clock (mirroring the sqlite store, and keeping expiry testable through the injectable now). Coordinating hosts must be NTP-synced; the default 60 s ttl dwarfs sane NTP drift, and shortening the ttl toward your skew budget is the tradeoff to watch.
  • One write region per run. The store proves single-region, multi-host fencing. Do not split one run's writers across regions over replicated postgres: a multi-region protocol is out of scope until proven, exactly as the improvement plan scoped it.
  • Pooling and backpressure. Every operation is one short transaction, so the pool is the backpressure: excess operations queue for a client instead of stampeding the server. Budget max across your whole fleet against the server's max_connections (workers times pool max, plus headroom), or front the fleet with pgbouncer in session mode. There is no store-side retry of transient connection loss; the engine's own retry discipline and the queue worker's lease loss handling stay the recovery story.
  • Backup and restore runbook. The store keeps everything in five tables under its schema (rulvar_entries, rulvar_meta, rulvar_leases, rulvar_epochs, rulvar_blobs), so standard postgres tooling applies verbatim: continuous archiving plus PITR (wal_level = replica, archive_command, restore to a timestamp) is the reference setup, and a plain pg_dump --schema=rulvar is a consistent logical snapshot (single-snapshot dump). After a restore to an earlier point, journals are simply shorter: resume replays to the restored tail and continues live from there, exactly the crash-recovery semantics the journal already promises. Two cautions: restore the WHOLE schema together (entries, meta, and blobs must come from one snapshot, or runs audit --repair reconciles a meta row that ran ahead), and never restore while workers hold leases against the new timeline (stop the fleet, restore, start; epochs stay monotonic because rulvar_epochs restores with the same snapshot).

The package also ships PostgresQuotaLimiter, the multi-host reference implementation of the core QuotaLimiter SPI: engine processes on any number of hosts pointing it at one database and schema (the store's own, or a dedicated one) enforce one global provider quota, with admission inside a single transaction serialized on a schema-wide advisory lock, reservations as rows so reconciliation works from any host, and both tables lazily pruned to two accounting windows. Its options are url, schema, the shared rules (snapshotted immutably at construction by the core's snapshotQuotaRules, so mutating the caller's array or rule objects afterwards changes no decision, bucket key, or recorded identity), a pool ceiling max, an admissionDeadlineMs, the rules-rotation opt-in acceptRulesUpdate (runtime-checked as a real boolean: it authorizes rewriting the schema's recorded rule identity, so truthiness is not enough), and an injectable now (window math only; infrastructure timeouts run on the real clock). Two bounds police every call: the exported QUOTA_LOCK_TIMEOUT_MS (2000 ms) bounds the lock-wait stage inside the transaction AND inside the bootstrap (a held boot lock used to wait unboundedly), and admissionDeadlineMs (default the exported QUOTA_ADMISSION_DEADLINE_MS, 5000 ms; refused at construction unless it is an integer above the lock bound it contains and at most the Node timer maximum of 2147483647 ms, above which a timer fires after about a millisecond) bounds the WHOLE path: lazy bootstrap, pool checkout, and the transaction together, so a call can no longer spend the lock bound once waiting for a connection and again waiting for the lock while counting as neither. Missing either bound throws into the engine's onLimiterError policy instead of hanging: the lock timeout as the driver's cancellation, the deadline as a typed QuotaDeadlineError that narrates only what actually happened in its phase, a transaction refusal destroys the held connection through release(err), a bootstrap refusal destroys the bootstrap's own connection so an abandoned bootstrap can never commit DDL or a rotation after the caller was refused, and an acquire refusal held nothing and says so.

Rules must be identical across coordinating hosts because buckets key on rule content, and the limiter enforces that instead of trusting it: boot records quotaRulesFingerprint(rules) (exported; sha256 over the canonical rule keys, insensitive to array order) together with a rules GENERATION in the schema's rulvar_quota_meta table under the boot lock, and an instance whose fingerprint differs is refused with a typed ConfigError naming both hashes and the schema, so a drifted host cannot silently split the budget into its own buckets (instances predating the fingerprint skip the check; only participants are bound). The generation is what makes rotation safe against hosts that ALREADY booted: every admission re-reads the recorded fingerprint and generation inside its own locked transaction and, on a mismatch, is refused with a typed QuotaGenerationError instead of admitting under retired bucket keys; the fenced host's next call re-boots into the honest boot-time refusal, and its outstanding reservations age out with their window, the same bounded residue a crashed process leaves. Rotation itself (acceptRulesUpdate: true) serializes on the SAME advisory lock admissions take, bumps the generation, and carries current-window consumption conservatively: a new bucket inherits the retired bucket's counters for the same (provider, model, tenant) dimension triple (the maximum, when several retired rules share the triple), so a raised cap grants only the difference and a lowered cap counts what was already consumed, while a genuinely new dimension starts empty; estimates held by fenced hosts settle nowhere and age out with the window, which errs toward under-admission, never over. The rollout procedure is therefore honest end to end: boot the new deployment with acceptRulesUpdate: true, expect every old host to refuse typed from that moment (fenced, not silently splitting), roll them to the new set, then remove the flag so drift is refused again. When sizing, count BOTH methods against the advisory lock: reserve and reconcile each take it once, so the lock sees admission attempts PLUS grants per minute (a granted call always comes back to settle), roughly twice the attempt rate when most admissions succeed; the queue is head-of-line (one slow admission delays every waiting host, up to the bounds above), which the short single-purpose transactions keep tolerable. What the engine does with a denial, and the rule model itself, is the subject of shared provider quotas.

The conformance suites, the cross-instance fencing tests, the adversarial multi-process soak, the quota limiter's contention and engine tests, and the kill-point suite (a child process SIGKILLed around each durable write, its pool connections severed mid-flight, resumed from another process) all run against a real postgres in this package's own test suite, gated on RULVAR_POSTGRES_URL (CI provides a service container; locally, any docker run postgres:16 works).

Choosing a store

SituationStore
Unit and integration testsInMemoryStore (the default)
One application process, durable runsJsonlFileStore + FileTranscriptStore
Multiple workers over a shared queue, one hostSqliteStore (leases and fencing)
Multiple workers across HOSTS, or an existing postgresPostgresStore (leases and fencing over one database)
Ops visibility, greppable and diffable journalsJsonlFileStore
Another backend (an object store, a KV, another RDBMS)Write your own against the SPI; see Writing a store

Encrypting what these stores persist is the serialization hook plus the shipped envelope encryption, see Data protection. The contracts are the only coupling point: any JournalStore that passes the conformance kit slots into createEngine unchanged, and the kernel's determinism does not depend on the backend. Whatever total order a store persists, the folds yield the same outcome on every store and every replay.

Durability expectations

What "durable" means, per store:

StoreSurvives a process crashConcurrent writers
InMemoryStoreNothing; no resume from another processNot applicable
JsonlFileStoreEverything appended; a torn tail line from a mid-append crash is repaired at load, whole records on it salvaged and a parseable unterminated tail terminated before the next append (RV701)One writing process per directory; no lease capability
SqliteStoreEverything appended, in one database fileSafe under leases; stale epochs are fenced out
PostgresStoreEverything appended, in the database (its durability is your postgres durability settings)Safe under leases across processes AND hosts; stale epochs are fenced out

A few engine-level guarantees hold on every durable store:

  • An awaited append is durable and visible before any of its effects run. Decision entries are written strictly before what they authorize, so a crash between decision and effect rolls forward on resume instead of re-deciding.
  • Completed entries are never repaid: replay serves them from the journal with zero live calls (the never-pay-twice invariant). A running entry whose terminal write never arrived is re-dispatched at-least-once; see Durability for the full crash-window story.
  • Turn-boundary checkpoints require a durable TranscriptStore. With one configured, an agent interrupted mid-loop resumes from its last completed turn; without one, the run's journal still replays, but in-flight agent turns are repaid.

The journal is plaintext by default

Journal payloads are stored as-is, because replay is the product: the engine re-reads entries byte-for-byte. Secret masking applies at the telemetry boundary (emitted events), never to stored entries. If your prompts or step values are sensitive at rest, use the serialization hook below or put the store on an encrypted volume. RunMeta is not hooked (the serialization hook covers journal entries only), and RunMeta.argsHash is a deterministic, unsalted SHA-256 of the genesis args: it reveals when two runs shared identical args and low-entropy args are recoverable by hashing candidate values, so treat meta and rulvar inspect output as sensitive alongside the journal and transcripts.

Encrypting stored bytes

The engine offers one policy point between itself and persistence: a serialization hook applied at the append/put boundaries and symmetrically at load/get. Stores stay dumb; the engine wraps whatever stores you configured, and engine.stores exposes the wrapped instances so every reader passes the same policy point.

ts
const engine = createEngine({
  adapters: [anthropic()],
  stores: { journal: store, transcripts: blobs },
  serialization: {
    transcripts: {
      toStored: (ref, blob) => encrypt(blob),   // your cipher
      fromStored: (ref, blob) => decrypt(blob),
    },
  },
});

The hook must be symmetric: fromStored(toStored(e)) has to reproduce the entry byte-identically, because content keys, the replay disposition, and the folds all read loaded entries. Encryption satisfies this; lossy redaction of journaled content voids replay for the affected entries (forward matching reports the misses honestly) and is a deliberate trade, never a default. Kernel identity fields (seq, scope, key, ordinal, kind, status, hashVersion) pass through unmodified, and leases and RunMeta are not hooked: fencing tokens are not secrets, and the meta record is written whole by the engine, so there is nothing for a payload policy to intercept.

Do not read "not hooked" as "disposable". The journal entries stay the sole source of truth for paid work and replay, and within RunMeta only the listing conveniences read as summaries: status, name, and tags serve listRuns, and the hash-version summary fields (hashVersionLow/hashVersionHigh) are advisory by contract, with the journal authoritative. Every other field must round-trip byte-stably, unknown fields included (persist the record opaquely and additions never break you): the engine restores a resumed run's immutable ceiling from budgetUsd (a store that drops it silently uncaps the resume), rebinds and rehydrates through workflowName/workflowHash/workflowSourceRef (losing them strands compiled runs and voids binding checks), seeds each segment's telemetry counters from segments, and hosts verify re-supplied resume args against argsProvided/argsHash. The conformance kit checks the round-trip of all of these.

Where to go next