Writing a store
rulvar persists run truth through a deliberately tiny storage seam, and the seam is frozen: the journal contract has exactly five methods, it has not grown since 1.0, and every mechanism added since (suspensions, abandoned branches, plan revisions, reuse-by-reference) rides ordinary appends plus pure folds over loaded entries. That makes a third-party store a small, finishable project. This page walks you through building one, from the byte contract to a green conformance run and a publishable package.
If you have not read Stores yet, start there: it covers the seam from the user's side and the shipped implementations. This page is the author's side. The reference implementation to crib from is SqliteStore in @rulvar/store-sqlite; the executable definition of correctness is @rulvar/store-conformance.
| Contract | Required? | Holds |
|---|---|---|
JournalStore | Yes | Journal entries and RunMeta records |
LeasableStore | Optional capability | Adds run ownership for multi-worker deployments |
TranscriptStore | Optional sibling seam | Large blobs: transcripts, checkpoints, worktree patches |
The five-method byte contract
Everything you implement is imported from @rulvar/core; a store package depends on nothing else.
import type { JournalEntry, JournalStore, Lease, RunFilter, RunMeta } from '@rulvar/core';
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>;
}Your store is a dumb byte mover. The kernel above it derives every fact (replay decisions, budget ledgers, plan state) by folding loaded entries; the store never interprets what it holds. Four obligations define correctness:
| Obligation | Meaning |
|---|---|
| Atomicity | An append is all-or-nothing; a reader never observes a torn entry. |
| Total per-run order | load(runId) returns entries exactly in append order, stable across calls. The store never reorders. |
| Read-your-writes | Once an append promise resolves, an immediately following load from the same client sees the entry. |
| Opaque payload | Entries come back byte-equivalent as JSON values. Unknown kinds and unknown fields pass through untouched. |
Opacity is the one authors break most often, and it is the one with the worst blast radius. Content keys, the replay disposition, and every fold read loaded entries verbatim; a store that deduplicates, normalizes key order, trims fields, or coerces numbers silently corrupts replay identity, and the run pays for work it already paid for. Never parse a payload; store the serialized bytes and hand them back.
Two structural rules complete the contract:
- Meta separation. The engine writes
RunMetathroughputMetaas its own record, precisely so thatlistRunscan filter bystatus,name, andtagswithout ever parsing a journal payload. Keep the two record types apart in your schema. delete(runId)removes the journal and the meta (and any lease state you keep). It does not touch transcript blobs: the engine owns that cascade (Engine.deleteRunlists and deletes blobs first, then calls yourdelete), so stores never reach into aTranscriptStore.
There is no compare-and-swap, no entry mutation, no query language, and nothing for you to validate inside a payload. In particular, do not enforce seq contiguity: seq belongs to the kernel. Your store checks nothing about contents; it only preserves order.
The lease capability and fencing
A plain JournalStore asserts one writing process per run. To support queue deployments, where any worker may pick up a run, implement the lease capability:
interface LeasableStore extends JournalStore {
acquire(runId: string, owner: string): Promise<Lease>;
renew(l: Lease): Promise<void>;
release(l: Lease): Promise<void>;
}
type Lease = { runId: string; owner: string; epoch: number };The semantics, all of which the conformance kit checks:
acquireon a run whose lease is currently held and unexpired rejects with the typedLeaseHeldErrorfrom@rulvar/core. The error is retryable by contract: callers retry after the holder releases or the ttl elapses.acquireon an expired lease succeeds. Expiry means the run is free; only a live lease rejects.- Leases carry a store-configured ttl, and holders renew at an interval of at most ttl/3. The shipped
SqliteStoredefaults its ttl to 60000 ms and takes an injectable clock so expiry is testable without wall-clock sleeps; copy both decisions. - The
epochis a fencing token: monotonic per run, surviving release and expiry. Everyacquirehands out a strictly higher epoch than any lease that run has ever had. - Fencing: an
appendorrenewcarrying a lease that is not the current holder (stale epoch, foreign owner, or expired) rejects withLeaseHeldError, and the rejected entry must never become visible to a subsequentload.
That last rule is the entire point. During a leased resume the engine carries the lease on every journal append, so a worker that stalls, loses its lease, and wakes up later cannot corrupt the journal: its writes carry a stale epoch and your store refuses them. Nobody has to trust the zombie to notice it died. An append carrying no lease is not fenced; it asserts the single-writer precondition instead, which is the honest contract of embedded single-process use.
A complete minimal store
The store below is the smallest correct LeasableStore: in-memory maps, a JSON round-trip for payload isolation, per-run epoch counters that survive release, and an injectable clock. It passes the full conformance kit, and the same listing is exercised verbatim in rulvar's own test suite, so it cannot rot.
import {
LeaseHeldError,
type JournalEntry,
type JournalStore,
type Lease,
type LeasableStore,
type RunFilter,
type RunMeta,
} from '@rulvar/core';
export interface CommunityMemoryStoreOptions {
/** Lease ttl in milliseconds; the reference default is 60000. */
ttlMs?: number;
/** Injectable clock for deterministic expiry tests. */
now?: () => number;
}
export class CommunityMemoryStore implements LeasableStore {
private readonly entries = new Map<string, string[]>();
private readonly metas = new Map<string, RunMeta>();
private readonly leases = new Map<string, { lease: Lease; expiresAt: number }>();
private readonly epochs = new Map<string, number>();
private readonly ttlMs: number;
private readonly clock: () => number;
constructor(options: CommunityMemoryStoreOptions = {}) {
this.ttlMs = options.ttlMs ?? 60_000;
this.clock = options.now ?? Date.now;
}
/** The current holder, or undefined once expired: expiry frees the run. */
private liveLease(runId: string): Lease | undefined {
const held = this.leases.get(runId);
if (held === undefined || held.expiresAt <= this.clock()) {
return undefined;
}
return held.lease;
}
private assertFencing(lease: Lease): void {
const live = this.liveLease(lease.runId);
if (live === undefined || live.owner !== lease.owner || live.epoch !== lease.epoch) {
throw new LeaseHeldError(
`stale fencing epoch for run '${lease.runId}': (owner ${lease.owner}, epoch ` +
`${lease.epoch}) is not the current holder; nothing became visible`,
);
}
}
async append(runId: string, e: JournalEntry, lease?: Lease): Promise<void> {
if (lease !== undefined) {
this.assertFencing(lease);
}
// Serialize BEFORE the push: a JSON.stringify failure appends nothing
// (atomicity), and the string snapshot isolates the store from later
// caller mutation (opaque payload).
const row = JSON.stringify(e);
const rows = this.entries.get(runId) ?? [];
rows.push(row);
this.entries.set(runId, rows);
}
async load(runId: string): Promise<JournalEntry[]> {
return (this.entries.get(runId) ?? []).map((row) => JSON.parse(row) as JournalEntry);
}
async putMeta(m: RunMeta): Promise<void> {
this.metas.set(m.runId, JSON.parse(JSON.stringify(m)) as RunMeta);
}
async listRuns(f?: RunFilter): Promise<RunMeta[]> {
return [...this.metas.values()].filter(
(m) =>
(f?.status === undefined || m.status === f.status) &&
(f?.name === undefined || m.name === f.name) &&
(f?.tags === undefined || f.tags.every((tag) => m.tags?.includes(tag))),
);
}
async delete(runId: string): Promise<void> {
this.entries.delete(runId);
this.metas.delete(runId);
this.leases.delete(runId);
}
async acquire(runId: string, owner: string): Promise<Lease> {
const live = this.liveLease(runId);
if (live !== undefined) {
throw new LeaseHeldError(
`run '${runId}' is leased by '${live.owner}' (epoch ${live.epoch})`,
);
}
// The epoch counter outlives releases and expiries: a returning
// holder can never reuse an old epoch, so its stale appends stay
// rejectable forever.
const epoch = (this.epochs.get(runId) ?? 0) + 1;
this.epochs.set(runId, epoch);
const lease: Lease = { runId, owner, epoch };
this.leases.set(runId, { lease, expiresAt: this.clock() + this.ttlMs });
return lease;
}
async renew(l: Lease): Promise<void> {
this.assertFencing(l);
this.leases.set(l.runId, { lease: l, expiresAt: this.clock() + this.ttlMs });
}
async release(l: Lease): Promise<void> {
this.assertFencing(l);
this.leases.delete(l.runId);
}
}Three implementation notes generalize beyond memory:
- Make acquire atomic. Durable backends must make the check-and-bump a single atomic operation.
SqliteStorewraps it inBEGIN IMMEDIATE; a SQL backend can use one conditionalUPDATE; an object store can compare-and-swap on a lease document. - Keep the epoch counter in its own record. Never store the epoch only inside the lease row: if release deletes the row and the counter with it, a later acquire restarts at epoch 1 and a zombie's old lease becomes current again. The counter must outlive every lease.
- Snapshot at the boundary. Whatever your backend, make sure a caller mutating an object after
append(or afterload) cannot mutate stored history. Serializing on the way in, as above, solves both directions at once.
TranscriptStore: the blob seam
Transcripts, turn-boundary checkpoints, and worktree patches are large, so they live in a sibling blob store and journal entries carry only references. The contract is four methods over opaque bytes (Bytes is Uint8Array):
import type { Bytes, TranscriptStore } from '@rulvar/core';
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>;
}The same discipline applies: blob contents are engine-internal, so store and return the bytes exactly. Two behaviors are contractual: get on a missing ref returns null, and delete on a missing ref is a no-op, never an error. As with the journal, the cascade over a run's blobs is engine-side: Engine.deleteRun deletes every blob list(runId) returns and then the journal, so your delete only ever removes one blob.
Certifying with the conformance kit
@rulvar/store-conformance is the executable definition of the seam: a store that passes it is a rulvar store, and a store that does not is not. Add it as a dev dependency and wire it into any vitest (or jest) suite:
pnpm add -D @rulvar/store-conformanceimport { describe, it } from 'vitest';
import {
journalStoreConformance,
leasableStoreConformance,
registerConformance,
} from '@rulvar/store-conformance';
import { CommunityMemoryStore } from './community-memory-store.js';
registerConformance(
journalStoreConformance(() => new CommunityMemoryStore()),
{ describe, it },
);
registerConformance(
leasableStoreConformance(() => new CommunityMemoryStore({ ttlMs: 150 }), { ttlMs: 150 }),
{ describe, it },
);The factory you pass must return a fresh, isolated store on every call; checks run against independent instances, so a file-backed store should create a new temp directory per call. Passing ttlMs to leasableStoreConformance enables the wall-clock expiry and renew-keeps-held checks; keep it small (about 150 ms) so the suite stays fast. Outside a test framework, every suite also runs standalone:
const suite = journalStoreConformance(() => new CommunityMemoryStore());
await suite.run(); // throws a descriptive Error on the first violationWhat the kit proves:
| Check | What it proves |
|---|---|
| The four byte obligations | Atomicity, total per-run order, read-your-writes, and byte-for-byte opaque payloads, including unknown kinds and fields. |
| Meta separation | putMeta and listRuns operate on separate records and honor the RunFilter fields. |
| Golden fold-state fixture | A fixed journal of resolution, noop, invalid, and abandon entries round-trips your store; the sha256 of the materialized fold state must equal the frozen reference hash, identical across every store. |
| Decide-once oracle | An end-to-end scripted race of two resolution attempts yields exactly one applied classification, and a replay-strict pass over your store then makes zero live calls. |
| Abandon fixture | Resume issues not a single live call inside an abandoned subtree: the covered dispatch derives skipped and contributes zero to the ledger fold. |
| Lease exclusivity | acquire on a held, unexpired lease rejects with the typed LeaseHeldError. |
| Epoch monotonicity | The fencing epoch never repeats for a run, across release and expiry. |
| Stale-append invisibility | An append carrying a stale epoch is rejected and never appears in load. |
| Ttl expiry and renew cadence | Expiry frees the run; renewing keeps it held (enabled by ttlMs). |
The golden fixture is exported for debugging. When the fold-state check fails, replay it by hand to see where your bytes diverge:
import {
GOLDEN_FOLD_JOURNAL,
GOLDEN_FOLD_STATE_SHA256,
foldStateSha256,
} from '@rulvar/store-conformance';
const store = new CommunityMemoryStore();
for (const entry of GOLDEN_FOLD_JOURNAL) {
await store.append('golden', entry);
}
console.log(foldStateSha256(await store.load('golden')) === GOLDEN_FOLD_STATE_SHA256);If that prints false, your store altered a payload somewhere between append and load; diff the loaded entries against GOLDEN_FOLD_JOURNAL field by field.
Common failure modes
- Normalizing payloads. Dropping undefined-like fields, reordering keys, or coercing numbers breaks the opaque-payload obligation and, downstream, replay identity. Store the serialized bytes.
- Shared mutable objects. Handing the same object to
appendbookkeeping and laterloadcallers lets a caller mutate history. Snapshot on the way in or the way out. - Resetting the epoch on release. A zombie writer can then reuse an epoch after a failover; the fencing conformance check will catch it, but design it right first: the counter lives outside the lease.
- Rejecting
acquireon an expired lease. Expiry means the run is free. Only a live lease rejects. - Enforcing
seqcontiguity in the store.seqbelongs to the kernel. The store checks nothing about payload contents; it only preserves order.
Packaging and versioning
A store package should be small and boring. The checklist the shipped stores hold themselves to:
- Depend only on the public SPI. Import
JournalStore,LeasableStore,TranscriptStore,Lease,LeaseHeldError, and the entry types from@rulvar/core; never reach into internals. Since the imports are types plus one error class, declare@rulvar/coreas a peer dependency with a wide range so your package never forces a second copy into the host. - Match the platform baseline. rulvar is ESM only and requires Node 22.12.0 or newer; publish your store the same way.
- Run the full conformance kit in CI, on every backend configuration you claim to support, and say so in the README. The kit is the compatibility statement: the seam is frozen, so a store that passes today keeps working across engine versions. Journal-format evolution happens inside payloads via per-entry versioning and is invisible to a correct store, precisely because payloads are opaque (see Journal compatibility).
- Exercise cross-process fencing where the backend supports it: two store instances over one database, one acquires, the other's appends must bounce. The
@rulvar/store-sqlitesuite shows the pattern. - Make the lease ttl configurable and documented, and take an injectable clock (
now) so lease expiry is testable without wall-clock sleeps. - State the durability model in the README: what survives a process crash, and which backend primitive makes
acquireatomic.
Version the package on your backend's terms; rulvar's own release policy is in Versioning.
Where to go next
- Stores for the user-side view of the seam and the shipped implementations.
- The journal for entry identity and the replay machinery your bytes serve.
- Durability for resume semantics and crash windows.
@rulvar/store-conformanceAPI and@rulvar/store-sqliteAPI for complete signatures.