Guarantees
Exactly what a confirmed write, a rejected stale write, and a held claim each promise.
When an Ablo write succeeds, the server has confirmed it. Concurrency behavior depends on the write form you choose: plain writes are last-write-wins; functional updates, stale guards, and claims add protection when a write depends on earlier state. This page is the precise list of what each form promises.
Claims don’t lock. If another writer holds the row, claim waits for them,
re-reads the fresh row, then hands it to you — so two writers serialize instead
of clobbering.
Confirmed Writes
Awaiting a schema model write resolves only after authoritative confirmation and returns the updated row.
const updated = await ablo.weatherReports.update({
id: 'report_stockholm',
data: { status: 'ready' },
});
If the call resolves, the write was accepted by the server. If it rejects, the typed error tells you exactly why — the most common reasons being failed authorization, a schema validation error, or a stale-state or claim conflict (each covered below).
Schema model writes return the updated model row.
Optimistic Local State
Schema model writes update local state optimistically. This keeps UI and agent tools responsive while the commit is sent to the server.
- The local model changes immediately, before the promise settles.
- The promise always waits for authoritative confirmation.
- If the server rejects the write, the SDK rolls back the optimistic change and raises a typed error.
The server remains the source of truth.
Stale-Write Protection
Use snapshot(...) and readAt when a write depends on state the agent already
read:
const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
if (!report) throw new Error('report missing');
await ablo.weatherReports.update({
id: report.id,
data: { status: 'ready' },
reads: [report],
});
The returned row carries opaque evidence. If it changed after the read, the server rejects the write instead of applying stale reasoning.
Context collects the same exact-row evidence when an action depends on several awaited values. External values remain informational and do not acquire this guarantee.
Two other dispositions exist. overwrite applies the write with no stale check
at all. notify holds the write, so the row is left as it stands, and hands
back a StaleNotification carrying the current value for the actor to reconcile
and re-issue; the rest of the batch still commits.
See Concurrency Convention for the full taxonomy, what each disposition is checked against, and where the convention stops.
A plain update({ id, data }) carries no stale premise. If no one holds a claim
on the target, it is last-write-wins. That is appropriate for independent values
such as status flags, but not for a read-modify-write calculation. For the latter,
use the functional update form or pass exact returned rows through reads.
Claim Coordination
The guarantee, not the how-to. Methods, the claim-state object, and the
claim.queuelive in Coordination.
Claims are live coordination signals. They are not database locks.
ablo.<model>.claim({ id }) serializes on contention: if another human or agent
already holds the row, the claim waits for them to finish, then re-reads the row
before handing it back, so you proceed from fresh state. Reads stay open while a
claim is held — ablo.<model>.claim.state({ id }) returns the current claim state
(or null) without ever blocking. A server read can pass ifClaimed: 'fail' to
error out, when it should not return a row while someone else is mid-edit. Reads
never block on a claim — to wait for a row to free up, claim({ id }) it (the
claim queues fairly behind the holder).
By default, a held claim rejects writes from other participants to the claimed
target. Contenders that call claim wait their turn; ordinary reads remain
open. An explicit model conflict policy can choose another disposition for a
participant kind. While you hold a claim, the matching
ablo.<model>.update({ id, ... }) is rejected with AbloStaleContextError if
the row changed underneath you after your claim point.
Agent Runs
Agents should import the same schema as the app and write through
ablo.<model>.claim(...) plus ablo.<model>.update(...).
Audit Trail
Attribution is not a separate log you opt into. It rides on the change itself. Every broadcast delta names the actor, the authority it acted under, the credential that authorized it, and the approval stage it was in:
{
modelName: 'weatherReports',
modelId: 'report_stockholm',
actionType: 'U',
actor: { kind: 'agent', id: 'weather-agent-v3' },
onBehalfOf: { kind: 'user', id: 'user_8f2a' },
capabilityId: '…', // the key the write was authorized by
confirmationState: 'auto', // previewed | approved | required_human_approval
createdAt: '2026-05-14T14:22:01.034Z',
}
actor and onBehalfOf are derived from the credential, not from the call site,
so an agent cannot name a different actor in its own write. capabilityId is
non-null for every agent and system commit, so a write can always be traced to
the key that made it, and from that key to the person it was issued to.
The stored history goes one step further than recording. Audit rows are chained
with a keyed hash, so the log is tamper-evident: verify-chain walks the chain
and, if it breaks, names the sequence number and the hashes that disagree. No
chain roots at an agent. The delegation root is always the person who set the
work in motion.
For agent work this is what answers, after the fact: what changed, who authorized it, which run did it, and whether a human was in the loop.
See Audit Log for the stored row shape, the filters, verification, and export.
Persistence
Ablo defaults to in-memory persistence (‘memory’), so nothing is written to disk unless you ask for it.
Opt into a durable browser cache that survives reloads when you need it:
const ablo = Ablo({
schema,
apiKey: process.env.ABLO_API_KEY,
persistence: 'indexeddb',
});
Node, SSR, tests, and agents use in-memory persistence (‘memory’) automatically.
Cache persistence and outbound-write recovery are separate concerns. Most clients need only the default memory cache: once the server confirms a write, the server is durable and the idempotency key makes a retry safe. A long-running worker that must also recover an unacknowledged write after its own process dies can opt into a durable write journal:
const ablo = Ablo({
schema,
apiKey: process.env.ABLO_API_KEY,
durableWrites: {
store: workerWriteStore,
namespace: 'report-worker',
},
});
The store can be backed by the worker’s workflow state, SQLite, or another
durable system. Actor identity is derived from authentication; namespace only
separates workflow or deployment lanes sharing the same store.
Storage Boundary
Your rows live in your database; Ablo holds change history and coordination state. Writes land in your Postgres through a scoped writer role and are confirmed from its authoritative change feed. For a database that cannot grant replication, Ablo uses a signed Data Source endpoint instead. See Connect Your Database.
Writes
Use ablo.<model>.create/update/delete for state changes. The server validates
authorization, stale state, active claim conflicts, and idempotency before
accepting the write.