Change Propagation
How one row's change reaches the rows and actors that depend on it.
How a change to one row reaches the rows and actors that depend on it, and how to keep a chain of dependent work fresh. This is the propagation half of sync groups;
identity.mdis the access half (who may read a group), andconcurrency-convention.mdis the convention this rests on.
Start from the problem
An agent reads workspace A to write document B. A moment later it reads B to write
block C. Between those steps someone else edits A. The agent is now building
C on a premise that has moved — and nothing about writing C looks wrong in
isolation. That is stale context, and it is the thing sync groups let you catch.
The recipe is one field on the commit: declare the group you read as a premise, and say what should happen if it moved.
// The agent read everything under workspace:abc to compose this write.
await ablo.blocks.update({
id: 'block-C',
data: { text: revised },
reads: [{ group: 'workspace:abc', readAt: watermark, onStale: 'notify' }],
});
At commit, inside the write transaction, the engine asks a single question: did
any delta routed to workspace:abc land after watermark? If nothing moved, the
write applies. If something moved, onStale decides — notify holds the write
and hands the agent a StaleNotification naming the group, so it re-reads
workspace:abc and regenerates; reject aborts the batch with a 409. The agent
never persists work built on a premise it can no longer see.
How you hear about it
Four channels carry “something changed”, and they answer four different questions. Pick by the question you have.
// A screen that stays current.
ablo.records.onChange((docs) => render(docs));
// Who else is in here, and what are they holding.
await using room = await ablo.records.join(documentIds, { ttl: '5m' });
room.peers;
// Stop this write if the thing I read moved while I composed it.
await ablo.blocks.update({ id, data, reads: [{ group: 'workspace:abc', readAt, onStale: 'notify' }] });
// Tell me later if this moves, even though I am not writing now.
await ablo.records.track({ id: 's-1' });
| Question | Channel | Arrives |
|---|---|---|
| What do the rows say right now? | onChange |
As deltas land, on the socket |
| Who else is working here? | join, then room.peers and room.claims |
As participants come and go, on the socket |
| Did the premise for this write move? | reads on the write |
On that write’s receipt, before it applies |
| Has anything I read moved since? | track |
On your next commit’s receipt |
| Stop me writing if it has | track with onStale: 'reject' |
Refuses that commit, at the chokepoint |
Two distinctions do most of the work here.
join is about people; track is about data. Both open a subscription and
both are scoped by sync group, which is why they look alike. join reports
participants: who is present, what they are doing, which rows they hold. track
reports the rows themselves: something you said you cared about moved, here is
the watermark to re-read it at. A tool that wants to avoid duplicating a peer’s
work needs join. A tool whose output goes stale when its inputs change needs
track.
reads guards one write; track outlives it. They speak the same
vocabulary and produce the same StaleNotification. A reads entry is checked
once, at the commit that carried it, and discarded. A track is persisted and
re-checked against every delta after it, so a long-running actor hears about a
change that landed while it was thinking, on the next commit it makes.
onChange and join need a live socket, so they are available on the default
WebSocket client. reads and track ride the commit, so they reach a socketless
actor over HTTP too, which is what makes them the notification path for agents
and workers.
Three ways a change reaches other rows
“A affects B and C” means three different things. The engine does the first two for you and leaves the third to you — on purpose.
Routing — who hears about a change. Every row belongs to one or more sync
groups, and a write fans out to all of them. A row also inherits its ancestors’
groups: editing a block stamps the delta with block:…, document:…, and
workspace:…, so everyone watching the workspace sees the block move. This is delivery,
resolved by walking the ownership tree at commit time. It routes the change; it
never recomputes a value.
Structural cascade — what disappears with a change. Deleting a workspace removes
its documents and blocks. The database does that through ON DELETE CASCADE, but a
database-level cascade emits no delta, so open clients would quietly hold rows
that no longer exist. The engine closes that gap: before the delete it snapshots
the subtree and emits a tombstone for each descendant, routed to the right
group. Watchers see the whole subtree vanish.
Value recomputation — what a change implies for derived state. If B holds a
number rolled up from A, the engine does not recompute B when A changes. It
surfaces that A moved and lets the actor decide what B should become. This is
the non-coercion principle: coordinate and report, resolve nothing by fiat.
Merging derived state is a judgment call, and for an agent in the loop that
judgment is the whole point.
The chain: A → B → C
Model a dependency as shared group membership. Put A and B in one group, B
and C in another, and you have wired the edges of a chain. What travels along
those edges is a signal, one hop at a time — not a recomputation.
A writes ──▶ group {A,B} ──▶ B hears it
│ B decides, B writes
▼
group {B,C} ──▶ C hears it
A’s delta lands in {A,B} and stops there. C is not in that group, so C
learns nothing from A directly. C advances only when B itself writes and
that new delta lands in {B,C}. B is the translator: it takes “A moved,”
decides what that means for its own state, commits, and its commit is what
reaches C.
The direction matters. The signal flows forward, A to B to C, and each hop is a real write an actor chose to make. The engine supplies the edges (group membership) and a stale signal on each edge (the premise check); the actors are the runtime that walks them. It is closer to a dataset an analyst recalculates cell by cell than to a reactive engine that recomputes the whole column for you.
Two consequences worth designing around:
- The chain runs as fast as actors react. If
Bnever acts on its signal, the chain stops atBandCstays as it was. Freshness is an actor responsibility; the engine guarantees the signal, not the follow-through. - Cycles don’t settle themselves. If
Cwrites back toA, each hop is a separate commit with its own stale check, and nothing damps the oscillation. Keep the dependency graph acyclic, or give one actor the job of reaching a fixpoint. Convergence lives above the engine.
Declaring the batch premise
reads[] declares what the commit was based on. Each entry is a premise, and
each governs the whole commit: if one goes stale, its disposition applies to
every write in the batch, not just one operation. You choose the granularity per
entry.
reads: [
{ group: 'workspace:abc', readAt: N, onStale: 'notify' }, // did anything in the workspace move?
{ model: 'Document', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move?
]
A group premise asks “did anything I was watching change?” — the native
Ablo granularity, and the right tool for the chain above. A row premise is
literal: this object, optionally these fields. A row premise with fields
conflicts only on real field overlap, so two actors editing disjoint fields of
the same row don’t collide.
onStale has three settings, defaulting to reject:
notifyholds every write in the batch and returns aStaleNotification. For a group premise it carries the group name and the new watermark (observedSyncId); re-read the group at that point and regenerate. This is the setting a chain wants — the actor gets the truth and resolves it.rejectaborts the batch with astale_contexterror (409). The right default when there is nothing to reconcile and the write should simply not land.overwriteskips the check and lets the write land — last-write-wins, the explicit escape hatch.
Staying subscribed across commits: track
A batch premise guards a single commit: you state what you read, the engine checks it, the premise is gone. That fits an actor that reads and writes in one breath. It does not fit a long-running one — an agent that reads a row now, works for a few minutes, and writes much later. By the time it commits, the premise it would have declared is stale, and there was no commit in between on which to hear that the ground had shifted.
track is the durable half of the same idea. Register what you are watching and
it persists on the server; the next time you commit anything, a change that
landed on the tracked target since you registered rides back on your receipt —
the same StaleNotification an onStale: 'notify' premise would have handed
you, arriving on the write you were going to make anyway.
// Register interest and walk away — no write required.
await ablo.records.track({ id: 's-1' });
// …minutes of other work later, on your next commit…
const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } });
res.notifications; // populated if s-1 moved under you in the meantime
The target is a row ({ id } on the model verb) or a sync group (as a write
option, below). A track is an idempotent registration: calling it again refreshes
the same subscription rather than stacking duplicates, and once a change fires the
track re-baselines, so the same change notifies once. Your own writes to a target
you track never notify you — the signal is about what others did.
You can also register a track as part of a write you are already making, the
persisted companion to reads:
await ablo.records.update({
id: 's-1',
data: { title: revised },
reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
track: [{ group: 'workspace:abc' }], // and keeps watching after it
});
So reads is the premise for the commit in hand; track is a standing
subscription that outlives it. Both speak the same notification vocabulary.
Delivery is on your next commit’s receipt; a track does not yet push out of band
between commits.
Reporting, or gating: onStale
A track says what a moved belief should do to your next write. Same enum the
reads premise carries, minus the one mode that cannot apply:
await ablo.records.track({ id: 's-1' }); // notify (default)
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate
notify— the change rides your next receipt and the commit proceeds. You decide what it means.reject— your next commit is refused while that belief is stale, even if it writes an unrelated row. The gate is on what you know, not on what you are touching.
reject is for an actor that must not act on a stale picture — an agent that
read a row, reasoned for minutes, and is about to write based on what it
believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather
than a convention: the write cannot land.
The gate takes a key. It does not reopen on its own, because an agent that
retried blindly would land exactly the write reject was asked to prevent. You
re-read, then re-register the track to say so:
try {
await ablo.records.update({ id, data });
} catch (err) {
if (err.code === 'stale_context') {
const fresh = await ablo.records.get({ id: 's-1' }); // read
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge
await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands
}
}
A track-only call is never gated, so acknowledging is always reachable.
overwrite is not offered: it means “apply my write anyway”, and a track guards
no write of its own to apply.
Sizing groups
A group is the unit of both delivery and staleness, so its size is a real tradeoff. A change fans out to every subscriber of every group it touches, and a group premise fires when anything in the group moves — so a group that is too broad wakes actors for changes they don’t care about, and one that is too narrow misses the dependency you meant to track.
The rule of thumb: make a group the smallest set of rows that must stay mutually consistent. A workspace and its documents belong together because editing one changes what the others mean; two unrelated workspaces do not. Reach for finer, overlapping groups when you genuinely have a dependency chain to track, and keep them coarse everywhere else.
Where this is defined
- Access, meaning who may read or write a group, is
identity.md. - The convention behind non-coercion, the premise, and the notification is
concurrency-convention.md(§4 and §5). - The mechanics, the three coordination blocks underneath, are
coordination.md. joinand presence, the participant half of the table above, arecoordination.mdfor the claim stream andreact.mdforuseJoin.