Skip to content
AbloAblo Docs
Esc
navigateopen⌘Jpreview
On this page

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.queue live 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.

Was this page helpful?