Durability
Foundry is built so that a process can die at any moment (the controller, the execution daemon, a run) and nothing it accepted is lost or silently done twice. It gets there by writing what it owes before it acts, and by delivering again, under the same identity, anything it cannot prove finished. That only holds if the stores underneath are durable: in production that means a real database, not the in-memory or file adapters the examples use.
This page covers what Foundry guarantees, how it keeps those guarantees, what your code must do in return, and what a database needs to provide. For configuring each store, see Persistence.
The model
- Journal first. Foundry records an obligation before it acts on it: an inbound event before its run is queued, a schedule occurrence before the schedule moves past it, a voice notice's receipt before the provider is asked. A crash leaves either nothing (so the cause fires again) or a stored obligation (so the restart delivers it).
- Claims decide races. Wherever two processes, retries or webhooks could do the same thing twice, the first atomic insert-if-absent wins and the rest see the existing record. These claims are the operations a database adapter must get exactly right.
- Stable identities. Every obligation has an id derived from what it is, not from when it ran: the same webhook event, schedule occurrence or late-chat effect has the same id on every retry.
- At least once. When Foundry cannot prove something finished, it delivers it again. A crash after a side effect but before its acknowledgement repeats the work, so side effects must be idempotent on those stable ids. Nothing in Foundry makes an arbitrary external effect exactly-once.
- Recover on startup. A starting controller reads the journal and finishes what was left: pending inbound events, delegated tasks, the event journal, approvals, schedule occurrences, then activations from their stored cursors.
What is durable, and where
| State | Stored by | After a crash | Stable identity |
|---|---|---|---|
| Agent instances, conversations, subscriptions, workspace entries, inbox, tasks | Data adapter | Kept. | Instance id; provisioningKey for provisioned instances |
| Inbound events (webhooks, channels) | Data adapter: delivery claim, pending inbound record | A retried webhook is ignored; an event no run finished is delivered again at startup, in arrival order. | Delivery claim key (route, provider event id, agent) |
Addressed events (emitEvent, agent messages, durable-worker mail) | Data adapter: event journal | Pending events are delivered again; acknowledged ids stay for deduplication. | Journal id (handled.id), from target and event id |
| Delegated tasks and their results | Data adapter: the task's conversation and pending inbound record | A task queued or running is started again; an undelivered result or question is delivered again. | Delegation id |
| Approvals | Data adapter, with the event journal | Stay open (7 days by default); a later decision resumes the conversation at the waiting call. | Approval id |
| Schedules and sleeps | Data adapter: activation with cursor, occurrence journal | Re-armed from the stored cursor; an occurrence that came due is delivered under its original id and time. | source.occurrenceId |
| Voice-call notices | Data adapter: delivery receipts | Never told twice; an uncertain dispatch is held; a late-chat effect is retried. | Journal id, effectId |
| Transcripts | Your StoreAdapter | Kept. A run that is replayed appends its turn again after what the interrupted attempt wrote. | Message ids where Foundry sets them |
| Structured memory, goals, facts, forms | Memory adapters | Kept. | Your keys |
| Agent files (VFS) | Environment persistence | The last saved snapshot; work since then is lost. | Owner scope |
| The run queue, run status, live turns | Memory in the execution daemon | Lost. Work that a journal holds is delivered again; a direct API request must be sent again by its caller. | None |
Event history (/api/events) | Memory (or your observability adapter) | Lost by default. | None |
An interrupted model turn is never resumed mid-instruction: the work is delivered again as a new run, which starts from the conversation as stored.
What your code must make idempotent
Anything that leaves Foundry (a message sent, a card charged, a ticket filed) can happen twice when a crash lands between doing it and recording it. Key each effect on the id Foundry gives you, and let the receiving system refuse a repeat:
| Work started by | Key the effect on | Where to read it |
|---|---|---|
| A schedule or sleep | occurrenceId | context.request.source.occurrenceId (and scheduledFor) |
| An addressed event in your handler | The journal id | handled.id in context.onEvent |
| An inbound webhook or channel event | The provider's event id | context.request.source.eventId |
| A delegated task | The delegation id | context.delegation.id |
| A voice notice's late-chat fallback | effectId | The afterCall context |
Most providers accept an idempotency key (Stripe, most email and messaging APIs). Where one does not, record the effect yourself under that key before acting, or check for it first, in the same database.
What a crash does, step by step
| Killed while… | On restart |
|---|---|
| a webhook was being accepted, before its claim | Nothing was recorded; the provider's retry is accepted. |
| a run handling an inbound event was queued or running | The pending record survives, so the event is delivered again. |
| a schedule had come due but its occurrence was not stored yet | The cursor did not move, so it comes due again: same id, same scheduledFor. |
| an occurrence was stored but not yet handed to a run | Delivered at startup under its id. |
| a run finished its side effect but its result was not recorded | Delivered again: the effect repeats unless it was idempotent. |
| an approval was waiting | Still open; the decision resumes the conversation. |
| a voice notice had been handed to the provider | Never told again; a missing transcript line is written once. |
a direct request (runtime.request, the API, WebChat) was running | Lost: the caller sends it again. |
These guarantees assume an acknowledged write is durable. If your database can lose writes it already confirmed (an unflushed Redis append-only file, an asynchronous replica that takes over), a crash can undo a claim or a journal entry and the guarantee goes with it.
What the database must provide
- Durable acknowledged writes. When a write returns, it survives a crash of the database process and of the host.
- Atomic insert-if-absent for claims, and a unique constraint for provisioning keys.
- An atomic conditional update for workspace compare-and-set.
- Multi-record atomicity (a transaction, or an idempotent sequence) for purging a worker's data.
- Read-your-writes. Startup lists the journal; it must see everything already acknowledged, so read from the primary or with strong consistency.
- Lossless documents. Foundry treats records as opaque JSON. Store them whole and hand them back unchanged; index only the fields the adapter queries.
The atomic operations, by database
| Adapter method | PostgreSQL | Redis | MongoDB | DynamoDB |
|---|---|---|---|---|
claimInboundDelivery, claimEvent, claimScheduleOccurrence, claimEventReceipt: insert if absent, true only for the first caller | insert … on conflict (id) do nothing; won when a row was inserted | SET key value NX; won on OK | insertOne on _id; a duplicate-key error (11000) means lost | PutItem with attribute_not_exists(pk); ConditionalCheckFailedException means lost |
provisionAgent: one instance per key; every caller gets the winner | Unique index on the key; insert, and on conflict read the row by key | A Lua script that checks the key index and writes both keys | Unique index on provisioningKey; on a duplicate, find by key | TransactWriteItems: a key item with attribute_not_exists plus the agent item; on failure, read by key |
compareAndSetWorkspaceEntry: replace only if updatedAt is still the one you read | update … where updated_at = $expected; insert-if-absent for null | A Lua script comparing the stored updatedAt | updateOne({ _id, updatedAt: expected }); won when matchedCount is 1 | UpdateItem with updatedAt = :expected |
purgeAgentData: every owned record or none | One transaction | MULTI/EXEC or a Lua script | A multi-document transaction (needs a replica set) | TransactWriteItems (100 items per call), or delete in an order that is safe to repeat |
| Durable acknowledgement | Default (synchronous_commit = on) | appendonly yes, appendfsync always | writeConcern: { w: "majority", j: true } | Durable by default |
| Reads that see every acknowledged write | Default, on the primary | The primary | Primary reads, readConcern: "majority" | ConsistentRead: true on base-table queries (global secondary indexes are only eventually consistent) |
PostgreSQL (recommended)
PostgreSQL gives every guarantee above with its defaults, and every adapter method is a single statement except the purge. A complete, tested implementation of the data adapter and a transcript store is in examples/foundry-postgres. Its tests run the adapter contract against a real server, including concurrent claims from separate connections, and restart a real Foundry runtime on the same database. Every claim has the same shape:
/** Insert-if-absent: the obligation exists before the activation's cursor moves past it. */
claimScheduleOccurrence(occurrence: FoundryScheduleOccurrence) {
return run(async () => {
const result = await this.pool.query(
`insert into foundry_occurrences (id, activation_id, workspace_id, agent_id, status, scheduled_for, doc)
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
[occurrence.id, occurrence.activationId, occurrence.workspaceId, occurrence.agentId,
occurrence.status, occurrence.scheduledFor, JSON.stringify(occurrence)]);
return (result.rowCount ?? 0) > 0;
});
}- Store each record in a
jsoncolumn, which keeps the text as written, with the queried fields in their own columns.jsonbreorders keys; that is fine for an index, not for a document you hand back. - The default isolation level (read committed) is enough: each method is one statement whose constraint decides the race.
- Create tables in a deploy step or migration, not when
foundry.application.tsloads: that module loads in every run's process. - Back it up like any production database (point-in-time recovery). A restore rewinds everything Foundry owes to that point.
Redis
Redis can back the data adapter, but only when it is configured to keep what it acknowledges. With appendfsync everysec (the common default) a crash can lose about a second of confirmed writes: claims and journal entries included. Use appendonly yes with appendfsync always, and remember that replication is asynchronous, so a promoted replica can be missing the newest writes. Many teams keep the record of truth in PostgreSQL and use Redis only for caches.
Keep one key per record (foundry:event:<id>, holding the JSON) and a set per listing (foundry:events), written together in MULTI/EXEC. Claims are SET … NX. Provisioning and compare-and-set need a script, so the check and the write happen together. These scripts were checked against a real server:
-- KEYS[1] = provisioning key index, KEYS[2] = the new agent's record key
-- ARGV[1] = the new agent id, ARGV[2] = the new agent document
local existing = redis.call('GET', KEYS[1])
if existing then return redis.call('GET', 'foundry:agent:' .. existing) end
if redis.call('EXISTS', KEYS[2]) == 1 then return redis.error_reply('agent id taken by another provisioning key') end
redis.call('SET', KEYS[1], ARGV[1])
redis.call('SET', KEYS[2], ARGV[2])
return ARGV[2]-- KEYS[1] = workspace entry key
-- ARGV[1] = expected updatedAt ('' = must be absent), ARGV[2] = new updatedAt, ARGV[3] = new document
local current = redis.call('HGET', KEYS[1], 'updatedAt')
if ARGV[1] == '' then
if current then return 0 end
elseif current ~= ARGV[1] then
return 0
end
redis.call('HSET', KEYS[1], 'updatedAt', ARGV[2], 'doc', ARGV[3])
return 1On Redis Cluster, every key one script touches must share a hash slot: wrap the common part in braces (foundry:{agent}:…).
MongoDB
- One collection per record kind, with the record id as
_id. A claim isinsertOne; a duplicate-key error (code 11000) means another caller won. - A unique index on
provisioningKey(sparse or partial, since most agents have none) makes provisioning atomic. - Compare-and-set is
updateOne({ _id, updatedAt: expected }, { $set: … }); it won whenmatchedCountis 1. - Write with
{ w: "majority", j: true }and read from the primary. A purge in one transaction needs a replica set, which production deployments have anyway. - Documents are limited to 16 MB: keep large VFS snapshots in object storage and store a reference.
async function claim(collection: Collection, record: { id: string }): Promise<boolean> {
try {
await collection.insertOne({ _id: record.id, doc: record }, { writeConcern: { w: "majority", j: true } });
return true;
} catch (error) {
if ((error as { code?: number }).code === 11000) return false; // someone else claimed it
throw error;
}
}DynamoDB
- Claims are
PutItemwithConditionExpression: "attribute_not_exists(pk)"; aConditionalCheckFailedExceptionmeans another caller won. - Compare-and-set is
UpdateItemwithConditionExpression: "updatedAt = :expected". - Provisioning writes a key item (
pk = "provisioning#<key>") and the agent in oneTransactWriteItems, conditioned on the key item not existing. - Listings Foundry reads at startup (the event journal, pending inbound events, open occurrences) must see every acknowledged write. Model them as queries on the base table (one partition per kind, for instance) with
ConsistentRead: true. Global secondary indexes are eventually consistent and can miss a write made a moment before a crash. - Items are limited to 400 KB: store VFS snapshots, and very long records, in S3 with a reference.
SQLite and files
FileFoundryDataAdapter and the SQLite memory adapters are durable on one host (atomic rename and fsync, SQLite's write-ahead log) and are what the examples use, because they need no server. They suit development, demos and single-machine deployments with a persistent disk. They are not for several hosts, and the file adapter rewrites its whole state on every change, so it slows as state grows. See Persistence.
Processes and connections
- Every run executes in its own process, which loads
foundry.application.tsand the agent module again. Build the database client there, from configuration (an environment variable). Never pass a live connection or pool from the controller: each process opens its own. - Size the database for it: the controller plus up to
execution.maxConcurrentrun processes, each with its own pool. Keep per-process pools small, and let idle connections close (pg'sallowExitOnIdle) so a finished run's process can exit. - Run one Foundry controller per data store. Two controllers on one store would each re-arm every schedule and replay every pending obligation. For availability, restart the controller (a supervisor, a container platform); it recovers from the store.
Production checklist
- A database-backed data adapter with all the optional durability methods: event journal, pending inbound, approvals, schedule occurrences, delivery receipts, purge.
- A database-backed transcript
StoreAdapter, with permissions if you use approvals. - Durable memory adapters and VFS persistence if your agents use them.
- The database configured for durable acknowledged writes and read from the primary.
- Side effects keyed on the ids above.
- One controller per store, restarted automatically.
- Backups, and a restore you have tried.
- The adapter contract tests from
examples/foundry-postgres, run against your adapter. - A kill test:
kill -9the controller while work is due or running, restart it, and check that nothing was lost and every repeat carried the same id.