Storage
The StorageAdapter contract — the one seam you can't omit, and the guarantees it has to make.
Storage is the only required seam with real behavioural obligations. @intentface/latch-drizzle implements it for Postgres and SQLite; this page is what you need if you're writing another one, or debugging one.
createRuntime({ storage, context, agents });What it stores
Five record types. The adapter owns the owner key — an opaque string it derives from the principal — and every method that takes a principal scopes its read by it. The runtime's own machinery also calls a few methods by id (appendMessages, updateRun, getRun, heartbeatRun, reclaimRun, completeSchedule, noteScheduleFire). Those carry the owner key as an optional last argument, so the adapter can check it or route by it: see Owner keys on id-only calls. Reach for the Runtime operations rather than the adapter when you're serving a request.
| Record | Holds |
|---|---|
ChatRecord | id, owner, agent, title, kind, timestamps |
RunRecord | status, ownerKey (its chat's), identity blob, lease fields, attempt count, token usage, cost, error, the agent version it ran |
RunLease | { owner, fencingToken } — proof of single-writer ownership |
UsageRecord | per-run token/cost row for billing rollups |
ScheduleRecord | cron, timezone, prompt, opaque delivery, nextRunAt, lease fields |
Two fields are opaque and must round-trip byte-for-byte: RunRecord.identity (the serialized principal) and ScheduleRecord.delivery (the host's delivery target). Core never interprets either.
RunRecord.agentVersion is the stored agent-record version this run started on (null for code-declared agents, which have no stored record). It is written when the run is opened, not when it settles: a run that errors or crashes never reaches a terminal update, and "which version produced these failures" is exactly the question worth answering. It stays fixed across a resume.
ChatRecord.kind is an open vocabulary: "user" for conversations, "internal" for runtime-created sub-threads (the sub_… chats spawn_agent mints). listChats(principal, { kind: "user" }) is how a sidebar hides the machinery.
The methods
interface StorageAdapter<P> {
// Chats
createChat, getChat, listChats
// Messages
loadMessages, appendMessages
// Runs
createRun, updateRun, getRun, listRuns
// Durability
claimRun, heartbeatRun, reclaimRun, reapExpiredRuns
// Usage
recordUsage
// Schedules
createSchedule, listSchedules, deleteSchedule, bumpSchedule,
claimDueSchedules, completeSchedule
// Deletion (optional)
deleteChats, purgeOwner
}Guarantees you must provide
These are what make the runtime safe on multiple nodes. Getting them wrong produces bugs that only appear under concurrency — the expensive kind.
Claims are atomic and exactly-once
claimRun, reclaimRun and claimDueSchedules must be single atomic transitions (one conditional UPDATE … WHERE), not read-then-write. Every node's cron fires sweep() and runDue() concurrently; correctness comes entirely from the database refusing the second claimant.
Get this wrong and you get double-fired schedules and two workers streaming into the same run.
Fencing tokens gate stale writers
RunLease carries a monotonic fencingToken, bumped on every claim. Guarded writes must land only if (owner, fencingToken) still match the row.
The scenario: a worker's lease expires (it was slow, not dead), the reaper reassigns the run, and then the original worker wakes up and tries to write. Without fencing it overwrites newer state. This is why the token exists and why it must be checked in the WHERE clause, not in application code.
Message writes are fenced too. A durable turn passes appendMessages(chatId, messages, fence), where fence is { runId, owner, fencingToken }. The write lands only while that run still holds the lease, checked in the same statement or transaction as the write: a check followed by a separate insert leaves a window in which a reclaim can slip between the two. A refused write persists nothing and returns false, and the runtime then stops the turn as if its heartbeat had failed. Without fence the write is unconditional, as before. The shipped adapters evaluate the lease inside each upsert, and on Postgres also lock the run row FOR SHARE, so a reclaim waits for an in-flight write to commit.
Lease times come from one clock
Every node must agree when a lease expires. If each app server stamped and compared leases with its own clock, a server running 20 seconds fast would reap runs that their owner is still renewing. The shipped adapters take every lease time — claim, heartbeat, reclaim, supersede, reap — from the database's clock (clock_timestamp() on Postgres, julianday('now') on SQLite). reapExpiredRuns(now?) uses the adapter's clock when now is omitted, which is how the runtime calls it; pass now only to simulate time in a test.
A schedule consume reports whether it applied
claimDueSchedules stamps a lease that is never renewed, so a slow pass can lose its claim before it consumes the occurrence — to another node, or to a later runDue pass on the same instance (same owner id). completeSchedule is the write that settles which claimant fires:
- Fence it on
ownerand onclaim.leaseExpiresAtwhen given — the expiry that claim stamped, which is strictly later on every re-claim, so it tells two claims by the same owner apart. - Return
falsewhen the fence rejected the write. The runtime then does not fire; the current holder owns the occurrence.
Returning void is still accepted and treated as applied, so older adapters keep working — but they keep the duplicate-fire window this closes.
Ownership scoping is not optional
Every read that takes a principal must filter by the derived owner key — listChats, loadMessages, listRuns, listSchedules. Filter in the query, not after it: a capped scan that slices in application code silently stops being correct once a tenant has more rows than the cap.
getRun(id) takes no principal: the reaper and resume path look runs up by id. Never serve it straight to a caller; go through Runtime.getRun, which scopes the lookup for you.
Owner keys on id-only calls
Every call the runtime makes by id carries an OwnerScope as its last argument: { ownerKey }, the key your adapter put on the record it's acting on (ChatRecord.owner, RunRecord.ownerKey, ScheduleRecord.owner), handed back. The runtime never works out a key itself; it only passes yours on. The one exception is Runtime.getRun, which has no record yet and passes { principal } instead.
appendMessages(chatId, messages, fence?, scope?)
updateRun(id, patch, guard?, scope?)
getRun(id, scope?)
heartbeatRun(id, owner, ttlMs, fencingToken, scope?)
reclaimRun(id, owner, ttlMs, scope?)
completeSchedule(id, patch, owner, claim?, scope?)
noteScheduleFire(id, patch, scope?)It's optional, and an adapter may ignore it. Two reasons not to:
- Check it. Throw
OwnerKeyMismatchErrorwhen the key isn't the row's owner, so a bug can't write into another owner's run or chat. The runtime never retries a mismatch; recovery and schedules report it throughcreateRuntime({ onOwnerKeyMismatch })and leave the row alone. The shipped adapters check it. - Route by it. An adapter that keeps each tenant in its own schema or database reads the tenant from the key (
decodeOwnerKey(key)[0]) and goes straight there, with no global index of ids.
For either, return ownerKey on every run record, reaped and reclaimed ones included: crash recovery starts from reapExpiredRuns with nothing else in hand. Stamp it when the run opens, from its chat's owner.
reapExpiredRuns and claimDueSchedules are the two calls across all owners. An adapter with one schema per tenant keeps a small global table of active leases and due schedules for them, each row with its owner key.
Chat id collisions surface as a conflict
createChat must throw ChatConflictError when the id exists. Clients supply chat ids, so a caller can collide with a chat they don't own: the scoped getChat misses, and the insert races into the primary key. The typed error is what lets the HTTP layer answer 409 instead of 500.
import { ChatConflictError } from "@intentface/latch-core";
try {
await storage.createChat(principal, { agent, id });
} catch (error) {
if (error instanceof ChatConflictError) return new Response(null, { status: 409 });
throw error;
}Messages append, never mutate
appendMessages is the write path. Approval decisions, tool results and compaction boundaries are all recorded as appended message state — the log is the source of truth, and history is not rewritten in place.
One active run per chat. createRun and claimRun throw ChatBusyError (code chat_busy) when the chat already has a live active run — a lease that hasn't expired, or for non-durable runs an active row younger than the adapter's stale timeout. An expired one is superseded first (marked errored, fencing token bumped), so a crashed turn never blocks its chat. The shipped adapters enforce this with a partial unique index on (chat_id) WHERE status = 'active', so it holds under concurrency, not just in the happy path. reclaimRun returns null for a run whose chat has since started a newer active run. openTurn (implemented by the shipped adapters) opens the run and appends the user message in one transaction, so a refused turn persists nothing and a crash can't leave a run without its message. It is optional in the type for compatibility, but a durable adapter must implement it: without it a turn opens as two writes, and a process that dies between them leaves a run whose own message never landed — crash recovery then reads the previous turn's tail as that run's history. The runtime warns at startup when durability is on and openTurn is missing. This is what keeps two tabs, a webhook retry racing a reply, or a schedule firing mid-conversation from interleaving one history.
Deletion removes everything under a chat or an owner
Both are optional; without them runtime.deleteChat and runtime.purgeOwner refuse with UnsupportedOperationError (501 over HTTP).
deleteChats(principal, ids)removes the chats with their messages and runs, all or nothing: if any of them has a live run, none goes and it throwsChatBusyError. Ids that aren't the caller's are skipped; it returns the ids deleted. The runtime passes a chat together with the subagent threads it spawned, so a busy thread keeps the whole set.- Nothing lands for a deleted chat. A run, message or usage row written for a chat (or its run) that's gone must fail rather than become an orphan row nobody can see or delete. The shipped adapters look the chat up inside each such insert (on Postgres with
FOR SHARE, which also makes a run starting in a chat wait for that chat's deletion to commit). - Usage stays. Neither operation rewrites the usage ledger on its own:
deleteChatsleaves a chat's usage rows, andpurgeOwnerdoes whatPurgeOptions.usagesays (default"refile": move the rows torefileTo, by default the path one level up, and set theirrun_idto"erased"; or"keep", or"delete"). Report re-filed rows asusageRefiled, deleted ones asusage. purgeOwner(segments, { batchSize, usage, refileTo })removes every chat (with messages and runs) and every schedule whose owner key falls under the path: the key itself or anything starting with it plus/(isUnderOwner). Compare prefixes as plain strings (substr), never withLIKE: encoded keys contain%and_. Settle live runs first with their fencing token bumped, so a worker still running one has its writes refused. Stop each pass atbatchSizerows per table and returndone: false; the runtime calls again untildone, and stops with an error if a pass reportsdone: falsebut removed nothing.
Using the shipped adapter
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
const client = createClient({ url: "file:local.db" });
const db = drizzle(client, { schema });
// `migrate` executes DDL, so it takes the libSQL client — not the drizzle db.
await migrate(client);
const storage = createSqliteAdapter(db, { owner });The adapter takes the db, not a connection string — your app owns the connection and the pool. Tables are prefixed latch_*, and the package ships the migrations; consume those rather than hand-declaring the schema in your own migration folder.
Testing your own adapter
The failure modes above are invisible in normal use: an adapter that skips a fence or races a claim works until two nodes meet the same run. @intentface/latch-core/testing packages the guarantees as runnable checks: tenant scoping, message ordering, one active run per chat, racing claims and reclaims, heartbeats, the reaper, fenced message writes, chat id conflicts, and schedules (racing claims, stale completions, bumps, one-shots, deleting mid-fire). Set checksOwnerKey: true when your adapter stamps and checks owner keys, to add those checks. It runs under Vitest, Jest or node:test:
import { describe, it } from "vitest";
import { registerStorageConformance } from "@intentface/latch-core/testing";
registerStorageConformance({ describe, it }, "my adapter", async () => {
const db = await openTestDatabase();
return {
store: createMyAdapter(db, { owner: (p) => [p.org] }),
// Two principals your adapter maps to different owners.
principals: [{ org: "a" }, { org: "b" }],
close: () => db.close(),
};
});Each check opens a fresh target. The shipped SQLite and Postgres adapters run the same suite in CI.