crdt#
A small CRDT engine that encodes a JSON document as an append-only operation log and reads it back into plain JSON. Every replica that holds the same set of operations converges on the same document — not through a merge lattice, but through a deterministic sort followed by a plain linear fold.
The wire shape of operations, the reduced document body, and the atproto record
types are described as atproto lexicons under
lexicons/local/crdt/.
The pipeline#
Reading the log back into a document is three layers, each its own module:
- Sort (
src/sorter.ts) — order the op log deterministically by causal bucket, then lexically byopId. Ops whose dependencies can never be satisfied are dropped here. - Reduce, keyed by
target(src/reducer.ts) — each op names a value-objecttarget. Group the sorted ops bytargetand fold each group with the matching semantics — register (set/incr/assign) or collection (add/remove/insert/delete). The output is a document body: aROOTregister followed by one value object pertargetin first-appearance order. - Materialize (
src/materialize.ts) — starting fromROOT, walk the document body, resolving references and positional indexes into plain JSON.
References live entirely in layer 3. The reducer treats a reference as an opaque value and never interprets it, so the register and collection folds are unchanged by them.
import { reduceOperations } from "./src/reducer.js";
import { materializeDocument } from "./src/materialize.js";
const body = reduceOperations(ops); // DocBodyItem[] — ROOT first
const doc = materializeDocument(body); // plain JSON, or undefined if empty
See example/ for a worked op log, reduced body, and materialized
document.
Operations#
Every operation carries three common fields:
| Field | Type | Meaning |
|---|---|---|
opId |
string |
globally unique id of this op |
previousOpIds |
string[] |
causal predecessors this op observed (empty for a root) |
target |
string |
the value-object id this op writes to |
The $type field selects the op (atproto NSID form):
$type |
Family | Payload | Effect |
|---|---|---|---|
local.crdt.ops#set |
register | value: unknown |
overwrite the value (last write wins) |
local.crdt.ops#incr |
register | delta: integer |
add delta to the value, building from 0 if not a number |
local.crdt.ops#assign |
register | value: object |
shallow-merge keys, building from {} if not a plain object |
local.crdt.ops#add |
OR-Set | valueId?, value |
add an element (by ref when valueId is set, inline otherwise) |
local.crdt.ops#remove |
OR-Set | valueId: string |
remove every live element with that identity |
local.crdt.ops#insert |
RGA | anchor, side?, value |
splice a block of elements at an anchor |
local.crdt.ops#delete |
RGA | id, startIdx?, endIdx? |
tombstone elements from a source insert |
The previousOpIds edges are what give the sort its causal order: an op sorts
into a bucket one past the deepest predecessor it observed. A well-formed remove
or delete observes the adds it cancels, so those adds sort earlier and are live
when it runs — the observed-remove property throughout: you can only remove
what you have already seen.
Operation identity#
The sort boundary deduplicates by opId: two ops sharing an opId are folded as
one. So opId is the unit of identity, and how it is assigned decides which ops
merge. Mint it from a per-replica nonce (replica:counter) and only a literal
re-delivery dedups. Mint it as a hash of the op's content and two replicas
that independently produce the same edit produce the same opId — so two editors
who both fix "a typa" → "a typo" converge on a single insert instead of "typoo".
src/identity.ts mints content-addressed ids via cidForLex
(the same canonical DAG-CBOR + CID the repo already uses for record CIDs, so
clients agree on the encoding for free). Which fields are identity is per family,
governed by one rule: include previousOpIds exactly when the op's effect
depends on its causal position (what sorts before it). Position-independent ops
exclude deps so the same act always collapses; occurrence-bearing ops are not
content-addressed at all.
$type |
Strategy | Identity fields | Incl. previousOpIds? |
Why |
|---|---|---|---|---|
insert (RGA) |
content | target, anchor, side, value | yes | placement among concurrent peers; a re-insert after delete must stay distinct |
set (register) |
content | target, value | yes | LWW recency is causal-bucket order; re-assertion must stay distinct |
assign (register) |
content | target, value | yes | per-key LWW, as set |
add ref (OR-Set) |
content | target, valueId | yes | observed-remove: a re-add after a remove must stay distinct to re-appear |
add inline, set |
content | target, value | yes | opt-in set semantics (see below) |
remove (OR-Set) |
content | target, valueId | yes | cancels what sorts before it; two removes with different views differ |
delete (RGA) |
content | target, id, startIdx, endIdx | no | monotonic tombstone of elements named by stable id — position-independent |
add inline, default |
nonce | — | — | multiset: each add is a distinct member; collapsing would lose multiplicity |
incr (register) |
nonce | — | — | accumulation: two +1s must total +2, never merge |
Inline OR-Set add (no valueId) is the one per-collection choice — multiset
(the default, each add distinct by opId) or set (identical data collapses,
opt-in via mintOpId(op, { inlineAdd: "set" })). Nonce ops are not minted here:
they must carry a client-assigned unique opId, and mintOpId throws for them
rather than return a colliding hash.
Content ids only collide when clients produce byte-identical identity fields,
which takes agreement on three layers — the canonical encoding (handled by
cidForLex), the identity fields (the table above), and canonical op
construction in the editor: a fixed anchor choice for inserts (anchor the left
neighbour, side after), a canonical range for deletes (omit bounds for open
ranges), and a minimal, canonical dependency set where deps are identity.
mintOpId sorts previousOpIds so their order never matters, but it cannot
choose which deps a client lists — that is the editor's contract. The
conformance suite in test/identity.test.ts pins the
"same edit → same id, distinct edit → distinct id" boundary and should run in
every client.
Value objects#
Reducing the log yields a document body — a list of value objects, always
led by ROOT:
type DocBodyItem = DocValue | DocSet | DocSequence;
interface DocValue {
$type: "local.crdt.document#value";
id: string; // the op `target` — register identity, stable across writes
value: unknown; // the folded value, possibly containing references
lastOpId: string; // opId of the last op folded into this value object
}
interface DocSet {
$type: "local.crdt.document#set";
id: string;
set: Record<string, unknown>; // OR-Set members, keyed by element identity
lastOpId: string;
}
interface DocSequence {
$type: "local.crdt.document#sequence";
id: string;
seq: SeqItem[]; // run-length encoded RGA sequence (live values + tombstones)
lastOpId: string;
}
id and opId are distinct namespaces. id (the target) names a value object
— the unit of sharing; opId names a single op. lastOpId is the last op that
wrote the object — useful for attribution and change detection.
A document is the graph you get by picking a root id and following references.
For callers that already hold plain { id, value, lastOpId } registers,
materialize(rootId, objects) is a thin adapter over materializeDocument.
References#
A reference is a pointer to another value object, by its id:
interface Ref { $ref: string } // e.g. { "$ref": "user:bob" }
Two structural rules make references unambiguous:
Single-key rule#
A reference is exactly a one-key object whose only key is $ref and whose
value is a string id. An object that carries a $ref key alongside anything
else is not a reference — it is data. References are pure pointers; they
cannot carry sidecar fields. (See isRef in src/ref.ts.)
Depth-1 rule (one layer)#
Within a single value object, a reference may appear only as a direct member of the root container — a top-level field value, or a top-level array element. Anything that is itself a container (a nested object or array) is inline, frozen data and may not contain references.
{ "a": { "$ref": "test" } } // OK — ref is a top-level field value
{ "a": { "b": { "$ref": "test" } } } // not OK — ref nested one container deeper
You go deeper in the document by chaining value objects through references, not by nesting inline. Depth resets across a reference boundary, so a list is its own value object whose value is an array of references:
// "doc"
{ "id": "doc", "value": { "title": "Notes", "children": { "$ref": "list1" } } }
// "list1" — a list is a value object; its elements are refs
{ "id": "list1", "value": [ { "$ref": "n1" }, { "$ref": "n2" } ] }
// "n1" — inline nested *data* (no refs) is fine at any depth
{ "id": "n1", "value": { "text": "first", "addr": { "city": "NYC" } } }
Why depth-1: a value object's outgoing edges are then visible in its top-level members, so reference-graph operations (garbage collection, "who points here", cycle checks, copy/move analysis) are shallow scans rather than deep walks. It also pushes the things you'd want addressable for concurrent edit — list elements, sub-records — to become their own value objects. The restriction is reversible at zero cost: every depth-1 document is already valid under a looser rule and materializes identically, so it can be relaxed later if needed; the reverse would orphan references.
Positional references ($idx)#
A positional reference points at a position inside a materialized RGA sequence rather than at a value object:
interface IdxRef {
$idx: { target: string; id: string; idx: number; after?: boolean };
}
Unlike $ref, $idx may appear at any depth of inline data (for example a
facet's byteStart / byteEnd). It resolves to the offset of element
<id>.<idx> within the fully-materialized target sequence — a byte offset
for a string sequence, an item index for an array one. after: true resolves to
the exclusive end boundary instead of the start.
Escaping#
The $ prefix is system-owned in value space. A writer escapes any data key
that begins with $ by doubling the leading $; the reader reverses it. (See
escapeKey / unescapeKey in src/ref.ts.)
| User's literal data | Stored form | Materialized back |
|---|---|---|
{ "$ref": "x" } |
{ "$$ref": "x" } |
{ "$ref": "x" } |
{ "$$weird": 1 } |
{ "$$$weird": 1 } |
{ "$$weird": 1 } |
{ "color": "red" } |
{ "color": "red" } |
{ "color": "red" } |
The invariant this buys: a raw single-key { "$ref": ... } in stored data is
always a real pointer, never accidental user data, because any genuine $ref
key was escaped to $$ref. Behavior on malformed input (a raw reference deeper
than depth-1, or an unescaped $ data key) is unspecified — writers must follow
the contract.
Materialization#
materializeDocument(body, rootId?) resolves the document body into plain JSON,
starting from rootId (default "ROOT").
Walking a #value object's root container, each direct member is either:
- a reference → resolve it (recurse into the target value object), or
- inline data → copy it through, unescaping keys and resolving
$idxat every depth.
#set materializes to an array of its members' values (identity keys dropped).
#sequence materializes to a re-joined string (string-origin runs) or an array
(array-origin elements).
Absence#
A reference that resolves to nothing is absent, and absence collapses:
- object field → the key is dropped
- array element → the element is dropped and the array compacts
- document root → the document is empty (
undefined)
A reference resolves to nothing in two cases:
- Dangling — the target
idhas no value object. - Cycle — the target
idis already being expanded on the current path.
Cycles are path-based, not global#
The "already being expanded" check is against the chain of ancestors on the
current resolution path, not a global "first occurrence wins" set. So
a → b → a becomes a → b → nothing (the back-edge collapses), while a
diamond — two places referencing the same id without a cycle — legitimately
expands in both places. Global de-duplication would blank out the second
appearance, which would break copy (copy is two slots referencing one id, and
both must expand).
Collections: the OR-Set#
An OR-Set (observed-remove set, src/orset.ts) is a value
object whose target is written by add / remove ops instead of register ops.
It folds to a #set keyed by element identity, which materializes as an array.
An element is added one of two ways, and each way is removed by naming the
identity it carries:
| Add | Element | Identity | Removed by |
|---|---|---|---|
add with valueId |
{ $ref: T } |
the valueId |
remove { valueId: T } |
add without valueId (inline value) |
inline data | the add's opId |
remove { valueId: <add opId> } |
- By ref, the element's identity is
valueId(the value-object id it points at). Adding the samevalueIdtwice is one element (refs dedup by target);removecancels every live ref with that identity. - Inline data has no stable id, so its identity is the add op's own
opId. Two inline adds of identical data are two distinct, independently removable elements;removenames theopIdof the add to cancel.
Like the registers, the fold is a plain linear pass over the causally-sorted op
stream — convergence comes from the deterministic sort, not a merge lattice. A
well-formed remove causally follows (via previousOpIds) the adds it cancels, so
those adds sort earlier and are live when it runs; a later re-add sorts after
the remove and re-appears. This is the observed-remove property: you can only
remove what you have already seen.
Collections: the RGA list#
An RGA (replicated growable array, src/rga.ts) is a value
object whose target is written by insert / delete ops. Like the OR-Set it
folds to a #sequence that materializes as a string or array; what it adds is
order — elements live at stable positions and new elements are inserted
relative to an existing position.
Elements#
A single insert inserts several elements at once. Its value is:
- a string — one element per Unicode code point (
"hi"→ two elements), - an array — one element per entry, each a reference (
{ $ref }, recognized structurally as everywhere else) or inline data, or - any other JSON value — a single inline element.
Each element's identity is "<opId>.<index>": insert c1 of "hi" yields the
elements c1.0 and c1.1. That per-element id is what later inserts anchor on
and what deletes name.
Insert: anchor + side#
| Field | Meaning |
|---|---|
anchor |
an existing element id, or the sentinel "BEGIN" / "END" |
side |
"before" or "after" (defaults to "after") |
value |
the element(s): a string, an array, or a single value |
The block is spliced immediately before/after the anchor's current position.
BEGIN and END are the virtual ends of the list — after BEGIN is the front,
before END is the back (either side of a sentinel resolves the same). As with
the other types the fold is a linear pass over the causally-sorted stream, so
"the anchor's current position" is identical on every replica; concurrent inserts
at the same anchor each splice nearest it and so land in a stable, deterministic
order. Causality keeps an anchor present when an op referencing it runs; an
insert whose anchor is genuinely missing is dropped.
Delete: observed-remove of known elements#
delete names a source id (the opId of the insert that created the block)
and an optional startIdx / endIdx sub-range (inclusive); with no range it
tombstones the whole block. The fold tombstones each named element: it stays
in the list, so inserts anchored on it (even a deleted one) still resolve, but
it drops out of the projected value. Because a delete names ids rather than a
positional span, an element inserted into that span concurrently has a
different id, is not named, and survives — the observed-remove property again:
you can only delete what you have already seen.
Collections transform across families#
The OR-Set and the RGA fold to the same substrate — an ordered list of
element nodes (a ref by target, or inline data, live or tombstoned), defined in
src/collection.ts. They differ only in how their ops touch
it: an OR-Set add appends (refs dedup by target) and remove filters out by
identity, while an RGA insert splices a block at an anchor and delete
tombstones by element id.
Because the substrate is shared, a single target may be written by both
families, and its type transforms across the op stream — there is no
conversion step, the one node list flows through whichever handler each op needs:
- an
insertcan anchor on an element an OR-Setaddcontributed (the element id is that add'sopId); - an
addappends onto an RGA list; aremoveimposes set dedup on refs the RGA inserted (cancelling every node pointing at the target); - a
deletecan tombstone an OR-Set-added element, and a tombstone does not count as live, so it never blocks a lateraddre-add.
As everywhere else, the handler is chosen per op and the fold is a linear pass over the causally-sorted stream, so order in that stream is all that decides the outcome. (Mixing a register and a collection on one target remains undefined — the transform is between the two collection families only.)
Copy / move / copy-on-write#
The machinery only knows aliasing; everything else is ordinary ops on top.
- move — write the destination to
{ "$ref": "A" }and drop the source in the same op set. - copy (alias, the default) — write the destination to
{ "$ref": "A" }. Both slots resolve to the same value object; edits toAshow through both. - copy-on-write — start as the alias above; the fork is just the next write
to the destination replacing the
$refwith fresh content. COW is not a primitive — it falls out of "alias, then a normal write replaces it."
Lexicon#
Three lexicons under lexicons/local/crdt/ describe
the wire shapes:
| File | NSID | Contents |
|---|---|---|
ops.json |
local.crdt.ops |
the seven operation defs |
document.json |
local.crdt.document |
the document record, body item types, $ref / $idx |
oplog.json |
local.crdt.oplog |
the append-only oplog record and attestation types |
lexicons/com/atproto/repo/strongRef.json
is included for oplog signature strong refs.
Validate and regenerate TypeScript with the @atproto/lex-cli tooling:
npm run lex:check # validate all lexicons
npm run lex:gen # regenerate src/lexicon/ from lexicons/local/crdt/
The local.crdt NSID prefix is a placeholder — swap it for a real namespace
before publishing.
Development#
npm test # vitest suite (test/fixtures.test.ts, driven by test/fixtures/**)
npm run test:watch
npm run typecheck # tsc --noEmit