How Dialog Works
Dialog is an embeddable database for local-first software. Every peer, such as a laptop or a phone, keeps its own working copy of each repository it uses, called a replica. An app reads and writes its replica directly, with no server in the way. When two replicas can both reach a storage service they share, they exchange what changed through it and end up agreeing.
This guide follows one piece of data through the whole system. It starts when an app writes a fact, follows that fact into a sorted tree of keys and down to the bytes on disk, and then carries it to another device, where it is merged and queried. Each chapter picks up where the previous one left off.
The guide is written for two kinds of readers. If you want to build something that talks to Dialog, or write a second implementation, you should find enough detail here to get started. If you are just curious how a local-first database can work, you can skip the byte tables and still follow the story.
Alice and Bob’s grocery list
Alice and Bob share a grocery list. Alice edits it on her laptop. Bob edits it on his phone. Sometimes they are both offline, and sometimes they edit the same item at the same time.
The same list appears in every chapter, so the same facts, keys and hashes turn up again and again. By the end you will have seen one grocery item as a sentence, as three keys, as a leaf in a tree, as bytes in a block, as an entry in a signed history, and as a row in a query result.
What the guide covers
- Facts: the one shape all data takes.
- Keys: how a fact becomes sortable bytes, three times over.
- The Search Tree: how sorted keys are cut into nodes so that equal data gives equal trees.
- Nodes: the bytes of a single node, and how a node says how to read itself.
- Blocks and Storage: content addressing, and where blocks live.
- Commits and History: versions, the log inside the tree, and signed heads.
- Identity and Capabilities: who may read and write, and how a remote peer checks.
- Sync: how Alice’s and Bob’s replicas converge.
- Queries: how an app asks questions of all this.
How to read the diagrams. Colors mean the same thing in every diagram:
- entity the thing a fact is about
- attribute what is said about it
- value what is said
- hash content addresses, signatures and other machinery
- critical something a reader must understand, or refuse
- Alice and Bob their replicas and everything they write
Bytes are drawn as boxes holding two hexadecimal digits. Where a byte is a printable character, the character is shown and its hex value sits beneath it. A bracket under a run of bytes names the field those bytes make up.
Status. Dialog is experimental. The formats described here, including the node layout, are drafts and may change before a stable release. Where the guide describes something that is still moving, it says so. The implementation lives in the dialog-db repository, and each chapter links to the code it describes.
Facts
Everything Dialog stores is a fact. A fact is one small statement about one thing, and it always has the same three parts:
Read aloud, it is a sentence: the grocery name of item 1 is “Oat milk”. Dialog’s own vocabulary follows that sentence. The attribute is called the, the entity of, and the value is.
A grocery list is a pile of such sentences. Alice’s list, after she adds two items, looks like this:
| the | of | is |
|---|---|---|
demo.grocery/name | item:1 | "Oat milk" |
demo.grocery/done | item:1 | false |
demo.grocery/name | item:2 | "Eggs" |
demo.grocery/done | item:2 | false |
demo.grocery/tag | item:2 | "breakfast" |
There are no tables and no rows here. An item is whatever facts share its entity. Adding a new kind of information, a quantity say, means writing facts with a new attribute. Nothing else has to change first.
Entities
An entity is the thing a fact is about. It is named by a URI. Dialog usually derives an entity from the data a thing starts out with: it hashes that data with BLAKE3 and writes the hash as a did:key:z6Mk… URI. It has the shape of a public key, but it is only a hash, and no one holds a private key for it. The same starting data always gives the same entity.
Those URIs are long, so this guide writes short ones such as item:1 instead. Any URI the standard URL parser accepts works as an entity, and Dialog stores it in its normalized form.
An entity carries no data of its own. It exists only as long as some fact mentions it.
Relations and attributes
The middle part of a fact says what the fact is about the entity. What a fact stores there is a name, like demo.grocery/name, and that name is called a relation: the set of all the facts that share it. It is written as a domain and a name, separated by a slash:
- The domain says whose vocabulary the relation belongs to. It is written in reverse domain notation, like the domain authority of an AT Protocol NSID:
demo.grocerystands forgrocery.demo. Two apps that both saynamedo not collide, because their domains differ. A domain is lowercase letters, digits, hyphens and dots. - The name is lowercase kebab-case: letters, digits and hyphens, starting with a letter, like
nameorpicked-up-at.
The whole relation is at most 64 bytes. This guide uses the domain demo.grocery.
A relation is a name and nothing more. It does not say what type its values have, or how many of them an entity has. An app says that when it declares an attribute, which is a relation together with how to read it:
- a description, for people reading the data;
- the value type, such as
text:orboolean:; - a pick: which of an entity’s facts in the relation a read returns.
The pick an attribute gets unless it says otherwise is last, the newest fact, so the attribute reads one value. The pick all reads every fact, so the attribute reads many. A grocery item has one name and one done flag, and any number of tags: the name and the done flag are read under last, the tags under all. The other picks, max, min and top, also read one value: the greatest, the least, or the best ranked of a list.
Two apps can declare different attributes over the same relation, one reading a quantity as a whole number and another as it is. The facts do not change. Most of this guide can say attribute for both, since the grocery list reads each relation one way. The indexes in the next chapter do too: the relation’s place in a key is called the attribute, which is where EAV, AEV and VAE get their A.
Domains that start with dialog. are reserved. An app must not assert or retract facts in them, and Dialog rejects a transaction that tries. They are reserved so that Dialog can keep its own things in the same tree as an app’s data, as facts, with no risk that the two collide: rules and concepts, the record of each commit, stored assets, and other bookkeeping the later chapters describe.
ℹ️ If you are building something that Dialog itself might adopt one day, do not put it in a
dialog.domain. Put your own name in front, as inmyorg.dialog., so the relation readsmyorg.dialog.presence/cursor. The facts are yours and cannot collide with anything Dialog adds later, and the name still shows where the idea is meant to end up.
Values
A value is one of seven types. Each type has a number, and that number is written into every key that holds the value (see Keys):
| Type byte | Type | Example |
|---|---|---|
00 | bytes: | raw binary data |
01 | entity: | item:2, to point at another thing |
02 | boolean: | false |
03 | text: | "Oat milk" |
07 | record: | opaque structured bytes |
08 | symbol: | a relation name used as a value |
09 | number: | 2, -3, 1.5 |
The types are themselves identified by entities. text: is a URI like item:1, one that happens to have nothing after its scheme, and it names the type everywhere a type is written down: in an attribute’s declaration, in a rule, and in the hash that gives an attribute its identity. So a type is something facts can be about, like anything else.
An entity value is how facts link things together. If Bob adds the demo.grocery/store of item:2 is store:corner-shop, the item now points at a store, and the store can have facts of its own.
Numbers
Numbers in Dialog have one universal representation, and it can hold a number of any precision. There is no stored integer 2 next to a stored float 2.0. There is the number two, and 2, 2.0 and +2 are three ways of writing it. NaN is not a number, and Dialog refuses it wherever a value comes in.
Types like integer: and float: are not kinds of stored number. They are how an attribute chooses to interpret the numbers in its relation:
| Type | How it interprets a number |
|---|---|
number: | as it is |
integer: | as a whole number |
natural: | as a whole number that is not negative |
float: | as the nearest 64-bit float |
natural:8 to natural:128, integer:8 to integer:128, float:32, float:64 | the same, fitted to a width |
A type has a range, and a number outside it does not fit the attribute’s definition. For that attribute the number does not exist, even though it exists in the relation and another attribute over the same relation may read it. Through natural:8, a stored 300 or -1 is simply not there.
A number inside the range is viewed at the precision of the type. A relation holding 4.3 is observed as 4 by an attribute that reads it as integer:, and as 4.3 by one that reads it as float:. A fraction is always rounded down, so -4.3 is observed as -5.
ℹ️ Writes go through the same lens. Under the pick
last, asserting4through aninteger:attribute does nothing when the value observed through that attribute is already4, so a stored4.3stays4.3. Asserting5replaces the4.3with5.
Dialog works this way so that apps using different lenses on the same relation can share it. An app with lower precision does not destroy detail that an app with higher precision cares about, because writing back what it read changes nothing. And the app with lower precision still observes the data at the level of precision it is able to handle, instead of failing on a number it did not expect.
Saying things: assert and retract
An app never edits a fact in place. Facts cannot change. Instead, an app asserts and retracts whole facts:
- Assert says “this is true.” It adds the fact.
- Retract says “this is no longer true.” It removes the fact.
What an assertion does to the facts already there depends on the attribute’s pick. Under all, the new fact joins the others. Under any other pick, the new fact supersedes one fact: the one a read under that pick would have returned. Asserting that the eggs are done removes the fact that they were not.
Here is item 2 changing under two assertions and a retraction. Each panel shows the item’s facts after the change above it:
The stored facts carry no pick of their own. It comes from the attribute’s declaration, and Dialog applies it twice: when an assertion is written, and again when a query reads the attribute (see Queries). A write follows the read. Whatever a reader of the attribute would have seen is what the assertion supersedes, and nothing else. If two devices had each given the eggs a done flag without seeing the other’s, a read elects one of the two, and the next assertion supersedes that one. The other stays stored. A read under last never notices it, and a read under all can still find it.
Retracting a fact that is not there does nothing. Asserting a value that a read already returns writes nothing at all. Asserting a fact that is already there does not make a second copy: two writers asserting the same entity, attribute and value share one stored fact.
Nothing is ever null
Storage holds only facts that exist. If Bob has not given the eggs a quantity, there is no demo.grocery/quantity fact for item:2. There is no null, and nothing is written to say a value is missing. Absence is something a query can ask about, not something storage records.
When a fact became true
The three parts above say what a fact means. A stored fact also carries temporal information: the version of the data that asserted it. That is what lets two replicas tell which change came first, and which facts a retraction or a superseding assertion was meant to remove. It is covered in Commits and History.
An older field. A stored fact can also have an optional cause: the hash of a fact this one was meant to supersede. Older design notes lean on it heavily. Today Dialog uses it only to break ties that versions cannot order, and the real record of what superseded what lives in the history described in Commits and History.
Keys
Dialog keeps its facts in a sorted tree, and a sorted tree can only find things by the order of their keys. A fact gets looked up in three different ways, though. An app shows one grocery item, and needs every fact about item:1. It shows the whole list, and needs every demo.grocery/name. It asks which items are tagged "breakfast", and needs to start from a value.
No single sort order serves all three. So Dialog writes every fact three times, each time with its parts in a different order:
Each copy is called an index. The first byte of the key, the tag, says which index it belongs to. All the indexes live side by side in one tree, and the tag keeps them apart:
| Tag | Index | Parts in order | Used to find |
|---|---|---|---|
00 | EAV | entity, attribute, value | everything about a thing |
01 | AEV | attribute, entity, value | everything with an attribute |
02 | VAE | value, attribute, entity | things that have a value |
03 | History | version, entity, attribute, value | the log of changes |
04 | Asset | hash | assets, in trees written before assets were recorded as facts |
05 | Coverage | version, entity, attribute, value hash | what a change removed |
The first three hold the facts themselves. The last three are Dialog’s own bookkeeping, and later chapters come back to them. Nothing is added under tag 04 any more. A stored asset is recorded by an ordinary fact instead (see Blocks and Storage), and the entries older trees hold there are still read.
A key, byte by byte
Here is the EAV key for the grocery name of item 1 is “Oat milk”:
The key starts with the tag, 00. Then each part follows, written so that comparing the raw bytes gives the same answer as comparing the parts themselves:
- Strings (the entity URI, the attribute, and text values) are written as their UTF-8 bytes followed by a
00terminator. Any00inside the string is written as00 FF, so the terminator is never ambiguous. - The type byte of the value comes right before the value. It is the value’s type number from the Facts chapter;
03is text. - Numbers all carry the type byte
09, and are written as decimals, in the key encoding SQLite4 designed. The first byte says the number’s sign and how big it is. The digits follow, two decimal digits to a byte, and the last byte is marked, so a number ends itself and needs no terminator. A negative number has every byte after the first inverted. Bigger numbers sort later, whether they are whole or not, and a number has exactly one encoding: the quantity2is the two bytes18 04, and so are2.0and+2. - Booleans are one byte,
00for false and01for true.
The terminator is what makes prefixes sort first. item:1 ends with 00, and item:10 continues with 30 (the character 0) at the same position. Since 00 is smaller, every key for item:1 comes before every key for item:10, just as the strings would. Nothing about item 1 ever lands in the middle of item 10.
Why no field starts with FF. A terminator followed by FF would read back as an escaped zero. So no part of a key that follows a terminated string may begin with the byte FF, and the range bounds Dialog builds for scans use FE as their filler byte instead. The spill marker below starts with 01 for the same reason.
The VAE key holds the same bytes in a different order. The tag is 02, and the value comes first, led by its type:
Sorted, the list tells its own story
Put Alice’s keys in byte order and each index turns into a set of neat runs. Part of the AEV index is elided:
Every fact about item:2 is one contiguous run in EAV. Every demo.grocery/done is one run in AEV. In VAE the type byte comes right after the tag, so every Boolean sorts before every string: Boolean is type 02 and text is type 03. Among the strings, "Eggs" and "Oat milk" come before "breakfast", because uppercase letters have smaller byte values than lowercase ones.
A query is mostly a matter of picking the index whose runs match the question, then reading one run.
The value lives in the key
The value is part of every key, not stored beside it. That means a lookup by value in VAE finds the entity without reading anything else.
It also means a fact has exactly one place in each index. If Alice and Bob both assert that the eggs are done, both assertions build the same key, so each index holds one entry for that fact, not two.
That entry has a small payload next to its key. The payload holds no part of the fact, only its temporal information: the versions that asserted it, here both Alice’s and Bob’s, and the fact’s optional cause (see Commits and History).
Big values spill
A key holding a whole photo would make a terrible key. So every tree sets a limit on how long a value in a key may be, and a value whose encoded form is longer than that limit spills. The value’s place in the key keeps only the encoding of a short prefix of it, and the key ends with a 01 byte and the 32-byte BLAKE3 hash of the whole value. In EAV and AEV that tail comes right after the value; in VAE it comes after the entity, the key’s last part. The value itself is stored on its own, in the asset store, named by that hash:
The prefix keeps a spilled value sorting by how it starts, so a range scan over strings still finds it in about the right neighborhood. The hash keeps two different long values from sharing a key. A reader tells the two shapes apart by what is left after the key’s last part: nothing for an inline value, exactly 33 bytes for a spilled one.
The limit and the length of the prefix are not constants of Dialog. They are settings of the tree, and every node carries them in its header, as the Nodes chapter shows. Unless a tree says otherwise, the limit is 4,096 bytes and the prefix is the value’s first 64 bytes. A reader that guessed them wrong would build different keys and miss facts, so they are among the few settings a reader must understand.
Before 0.3. Releases before version 0.3.0 kept natural numbers, integers and floats in three separate bands of the index, each under its own type byte, so 2 and 2.0 were different keys. A tree written that way can still be read, but not added to: a commit on it fails with NeedsMigration, and Branch::migrate rewrites the branch into the layout this chapter describes.
Implementations. Keys are built by build_key in dialog-artifacts/src/key/varkey.rs. The string encoding is in artifacts/ordkey.rs and artifacts/ordvalue.rs, and the number encoding is the dialog-number crate. Spilling is decided by EncodedValue::new in key.rs.
The Search Tree
Every key from the last chapter goes into one sorted sequence. Alice’s grocery list is small, but a real repository holds millions of keys. Dialog stores that sequence as a tree of nodes, so that finding one key reads a few nodes rather than all of them.
The tree has one property that everything else in Dialog leans on: the same entries always make the same tree. An entry is a key and the small payload stored with it. It does not matter in what order the entries were added, or who added them. Two replicas holding the same entries in canonical form hold byte-for-byte identical trees, with identical hashes at the top. The rest of this chapter explains how the tree gets that property, what it buys, and when Dialog chooses to give it up for a while.
Cutting the sequence into leaves
To keep the pictures small, pretend the keys are just grocery names. In a real tree each of these is a full EAV, AEV or VAE key.
The sorted sequence is cut into runs, and each run becomes a leaf node. The question is where to cut. A B-tree cuts when a node gets full, which depends on the order things were inserted. Dialog instead lets each key decide for itself, with a coin flip that always lands the same way for the same key:
The coin is the key’s BLAKE3 hash. Dialog reads the first 8 bytes of the hash as a number between 0 and 264, and cuts after the key when that number falls below a threshold. Since the hash of a key never changes, neither does its draw. Given the same entries around it, butter cuts after itself on every replica.
The threshold depends on how heavy the entry is. An entry weighs its key’s length in bytes, plus the size of its payload, plus a fixed overhead of 64. The chance of a cut after an entry is its weight divided by 65,536. A typical fact entry weighs a few hundred, so the coin fires after roughly one entry in a few hundred. Heavier entries are more likely to end a leaf, so leaves average about 64 KiB of weight no matter how big their entries are.
The exact rule. Take blake3(key), read bytes 0 to 7 as a little-endian unsigned 64-bit number draw, and cut after the entry when draw × 65536 < weight × 264. The arithmetic is done in 128 bits, so it never overflows. Here weight also includes any weight carried over from a run of vetoed spots right before it (see the safety valves below), and a vetoed spot never takes the coin at all. An entry that weighs 65,536 or more always cuts, unless its spot is vetoed.
Separators and index nodes
Leaves need a parent that says which leaf holds which keys. For every cut, Dialog writes a separator: the shortest prefix of the first key after the cut that still sorts above the last key before it.
Real keys share long prefixes. The last key of one leaf might be the EAV key for item:1 and the first key of the next the one for item:2. Those differ only at the seventh byte, so their separator is the seven bytes 00 69 74 65 6d 3a 32, which reads as a zero byte followed by item:2. A separator is almost always much shorter than the key it stands for, which keeps parent nodes small.
An index node holds a list of links, one per child. Each link has the child’s separator, the child’s hash, and a one-byte estimate of how big the child’s subtree is, which the query planner uses. Here is Alice’s tree, with a root index node over four leaves:
To find oats, a reader starts at the root. oats sorts after m and before r, so it follows the third link, fetches the node with hash 07de…, and finds oats inside it.
Index nodes are cut into groups the same way leaves are. Each separator flips its own coin, from its own hash. A link weighs its separator’s length, plus the 32-byte child hash, plus 16 bytes of overhead, and its coin fires with probability weight / 65,536. A link whose coin fires starts a new index node, and its separator becomes that node’s separator one level up. The first four index levels each draw from a different 8 bytes of the separator’s hash, and a separator cuts a level only if it also fired at every level below, so one lucky separator does not cut every level at once. Short separators mean light links, so a single index node holds hundreds of them, and a tree three levels deep already covers a very large repository.
Why the order does not matter
Every cut depends only on the entries at that point: their hashes and their weights. Nothing depends on what was inserted first. So Alice and Bob can build the list in completely different orders and still end up in the same place:
A node’s hash covers its whole content, including the hashes of its children. So the root hash covers everything. If two roots are equal, the two trees hold exactly the same keys. If two roots differ, a reader can compare them link by link, skip every child whose hash matches, and descend only where they differ. That is how two replicas find what they disagree about without reading everything they agree on. Sync is built on this.
Changing the tree
A node is never changed in place. To add pasta, Dialog reads the path from the root down to the leaf where pasta belongs, writes a new copy of that leaf with pasta in it, and then a new copy of every node above it, since each parent must point at its child’s new hash:
Everything off the path is untouched and reused. An edit to a tree with millions of keys writes a handful of new nodes. The old root still names the old tree, whole and readable, which is what makes history cheap (see Commits and History).
After the edit, Dialog re-checks the coins along the new path. If pasta’s own coin had fired, leaf 3 would have split in two. The tree after an edit is exactly the tree a fresh build from the same keys would give.
A few safety valves
Coin flips are fair on average, but some key sets are not. Three rules keep the tree well shaped anyway:
- No huge separators. If two neighboring keys share their first 512 bytes, a separator between them would be over 512 bytes long. Such a spot is vetoed: its coin never cuts, at any level. Only the next rule may split there, at the leaf level, with a deliberately long separator that never cuts an index level.
- No endless leaves. A run where cuts were forbidden and that grows heavier than 65,536 is split anyway, at a spot chosen deterministically from the keys, so every replica picks the same spot.
- A hard ceiling. Any run between coin cuts that is heavier than three times 65,536 is split at allowed spots, where there are any, even if no coin fired.
The numbers used above, 65,536 and 512 and 3, the entry overhead 64 and the link overhead 16, and the fanout setting 8 behind the 256 below, are the tree’s format. They are the defaults, and every node records which values its tree uses. The Nodes chapter shows where.
Buffered writes
As Changing the tree showed, changing one fact rewrites every node on the path from its leaf up to the root: each node on that spine gets a new hash, so each is written again. When facts arrive a few per commit, every commit pays for a whole spine, and a commit that touches facts in several places pays for several. So by default, a commit does not push its changes all the way down. It parks them in a buffer in the root, on the link that leads toward where they belong:
Reads look at the buffers on the way down and merge what they find with what the leaves hold, so a buffered write is visible immediately. When the buffers on one node hold more than 256 operations, or more than 64 KiB of weight, the heaviest of them are pushed one level down until the node is back under half that limit. Over time, every operation reaches the leaves. Both limits come from the tree’s format: 256 is 28, the format’s fanout setting, and 64 KiB is its node size target. A commit then rewrites the root, plus whatever nodes a flush reaches, instead of a whole spine for every change.
A buffered tree is not canonical. Two replicas with the same facts can hold them in different buffers and so have different roots. Nothing breaks when that happens. A node’s hash still covers its buffers, so a root still names its content exactly, and the comparison described above still works. It just cannot stop early at the root, and does a little work to find that nothing differs.
When the canonical form matters, a commit can ask for it, and Dialog pushes every buffer down before sealing the tree. Bulk imports do this once at the end. Ordinary commits and sync do not.
Why buffer by default
Giving up the canonical form sounds like a steep price, since it is what lets two replicas holding the same facts recognize each other at the root. But that is rarely how replicas meet. It is unlikely that two peers start from nothing, insert the same facts in different orders, and then sync. The usual case is the grocery list: Alice and Bob start from the same tree, each adds a few facts of their own, and then they reconcile.
In that case what matters is how many blocks differ between their trees, because every differing block has to be exchanged. With canonical edits, each new fact rewrites the spine from its leaf to the root, and facts that land in different parts of the tree rewrite different spines. With buffering, the new facts sit in the root, and everything below it is still the base both replicas started from. Here Bob adds two facts that belong in different leaves:
So replicas that have diverged a little sync by exchanging a root or two, rather than a path of nodes for every change. The rare case where replicas diverge a lot, such as importing a large dataset, is where the canonical form earns its keep: a bulk import pushes every buffer down once when it finishes, and an app can ask for the canonical form on any commit that needs it.
Implementations. The tree is dialog-search-tree. The coins and separators are in src/distribution.rs (weight_paced_cut, weight_paced_seam_rank, shortest_separator). Canonical edits are TransientTree in src/tree/transient.rs. Buffered writes are HitchhikerTree in src/hitchhiker.rs. Comparing two trees is TreeDifference in src/differential.rs.
Nodes
The last chapter drew nodes as boxes. This one opens a box and looks at its bytes.
A node has to answer two questions before anyone can use it. The first is how do I read you? A reader must know the node’s layout before it can make sense of anything inside it. The second is what tree are you part of? The tree’s format, its node size target and the other numbers from the last two chapters, decides where keys go. A reader that guessed those numbers wrong would look for keys in the wrong places.
Dialog answers both questions at the front of every node. A node starts with a short prelude, then its body:
It works like an HTTP message: a fixed opening line, a few headers, then a body of plain bytes.
The prelude
The prelude has four parts:
- version says how to read everything after it. The layout described here is version
02. - kind says what the body is:
00for a leaf,01for an index node. - header length is the number of bytes of manifest fields that follow, so a reader can skip them without understanding them.
- manifest fields hold the tree’s format, as a list of settings.
After the fields come zero bytes, up to the next multiple of 16. The body starts right after, on a 16-byte boundary, because the body is read in place and needs that alignment.
Here is the whole prelude of a leaf in a tree created today:
There is one field, three bytes long. A setting equal to its default is never written, and a new tree differs from the defaults in one setting: value_encoding, which says how values are written in keys. Its value is 1, which names the encoding the Keys chapter described.
So every node of every new tree starts with the same 16 bytes, give or take the kind byte. A reader uses the kind byte to validate the body as a leaf or an index, then checks the 16 bytes with a single comparison.
Numbers in the prelude
Every number in the prelude is written as a bijou64 varint. Small numbers take one byte. Bigger numbers take a marker byte that says how many bytes follow:
| First byte | Total bytes | Range |
|---|---|---|
00 to f7 | 1 | 0 to 247 |
f8 | 2 | 248 to 503 |
f9 | 3 | 504 to 66,039 |
fa | 4 | 66,040 to 16,843,255 |
fb to ff | 5 to 9 | larger, up to 264 − 1 |
The ranges are offset so they never overlap: f8 00 means 248, not 0. So every number has exactly one encoding. That matters more than it looks. A node is named by the hash of its bytes (see Blocks and Storage), so if the same number could be written two ways, the same node could have two names.
The manifest fields
Each field is three things in a row: a code saying which setting it is, the length of its value in bytes, and the value. The field above is 05 01 01: code 5 (value_encoding), one byte long, value 1. Here is a leaf from a new tree that also sets its fanout to 4:
The new field is 02 01 04: code 2 (fanout_n), one byte long, value 4. It comes first, because its code is smaller. The header length says 6, and seven zero bytes pad the prelude out to 16.
Codes come from a public table. These are the ones defined today:
| Code | Name | Kind | Default | What it sets |
|---|---|---|---|---|
01 | inline_n | required | 4096 | values longer than this spill out of the key |
02 | fanout_n | recommended | 8 | buffers flush at 2n ops; with max_segment 0, the expected fanout |
03 | spill_prefix | required | 64 | how many bytes of a spilled value its key keeps |
04 | max_separator | recommended | 512 | separators longer than this never cut |
05 | value_encoding | required | 0 | how values are written in keys; trees are created with 1, the encoding the Keys chapter describes |
06 | max_segment | recommended | 65536 | the node size target the coins aim for |
08 | frame_ceiling_factor | recommended | 3 | nodes over this many targets are force-split |
0a | anchor_selector | recommended | 1 | how a forced split picks its spot |
0c | entry_overhead | recommended | 64 | weight added per leaf entry |
0e | key_overhead | recommended | 32 | weight added per key where the value is unknown |
10 | link_overhead | recommended | 16 | weight added per index link |
A few rules keep the fields canonical, so the same format always gives the same bytes:
- Codes appear in ascending order, each at most once.
- A field equal to its default is left out.
- Every length is exact, and every number uses its one bijou64 encoding.
A reader refuses a node that breaks any of these rules. A node that spells out a default, for example, is not a valid node, even though a lenient reader could make sense of it.
The table only ever grows. A code is never reused and a default never changes. Changing a default would silently change the meaning of every node that left that field out. A new default is a new field with a new code.
Fields a reader does not know
Suppose Bob’s phone runs a newer build of Dialog than Alice’s laptop. Bob’s build knows a setting Alice’s build has never heard of, and Bob creates a tree that uses it. What should Alice’s build do with that tree?
Every setting is one of two kinds:
- Recommended settings only shape the tree: they move where the cuts fall. A build that does not recognize one does not follow it. Alice’s build still keeps the field and writes it back into every node it edits, so it does not strip Bob’s setting from his tree. Its edits may come out shaped a little differently from the canonical tree, which costs some extra work when replicas compare trees, but never loses a fact.
- Required settings change how bytes are read.
inline_n,spill_prefixandvalue_encodingdecide how a value is laid out inside its key, and a reader that ignored them would build different keys and miss facts. A build that does not recognize a required setting is not compatible with the tree: it refuses to read it and needs to be updated.
A build that has never seen a code cannot look up which kind it is, so the code itself says: even codes are recommended, odd codes are required. inline_n (01), spill_prefix (03) and value_encoding (05) are the required settings today. Here is a leaf from a new tree that also changes one setting of each kind:
The three fields take 13 bytes, so this prelude fills its 16 bytes exactly and has no padding.
Within this layout, the only setting that can lock out an older build is a required one the tree actually uses. Trees that use only recommended settings stay readable by every build that reads this layout. A new layout version or node kind would lock older builds out too, which is why those are reserved for changes that need it.
Reading a node
A reader is strict. The version must be known, every length must fit, the fields must be sorted and canonical, the padding must be exactly zero, and the body must validate as the kind of node the prelude names. A node that fails any of these checks is not a valid node.
A tree’s nodes all carry the same field bytes, so a reader decodes them once and remembers the result by those bytes. After the first node of a tree, reading its format is usually one lookup.
The body
The body is the node itself, stored with rkyv so that its structure can be read in place without copying. A leaf’s compressed keys are decoded as they are read. The body’s shape depends on the kind:
- A leaf stores its keys column by column. In an EAV, AEV or VAE leaf, each key is split into its parts, tag, entity, attribute, value type and value, in its index’s order, and each part gets its own column. A history leaf keeps whole keys in a single column. A column where the same few values repeat, like the attribute column, becomes a small dictionary plus an index per entry. A column of mostly distinct values, like entities, is front-coded: each entry stores only how much it shares with the previous one and the bytes that differ. The payloads come after the keys, one per entry.
- An index node stores the prefix its separators share once, then the rest of each separator and where each one ends, then the child hashes, then the one-byte size estimates, then any buffered writes, grouped by the link they ride on.
The empty tree
An empty tree still has a format, so it is still a node: a leaf with no entries, carrying the manifest like any other. For a new tree that is the 16-byte prelude above and an empty leaf body. It is only ever a root. The first insert replaces it.
Implementations. The prelude and its reader are in dialog-search-tree/src/node/persistent.rs. The manifest and its field codec are in src/manifest.rs, checked against the code table format/table.csv. The full design, with its cost measurements, is notes/tree-node-format.md.
Status. This layout is a draft. The code table marks every field draft, and the layout may change before a stable release.
Blocks and Storage
A node, once written, never changes. That makes naming it easy: a node is named by the hash of its bytes.
The hash is BLAKE3, 32 bytes, unkeyed, taken over the whole node, prelude included. Anything stored this way is called a block, and tree nodes are blocks.
Bytes that are not nodes are named the same way, by their BLAKE3 hash, but they are kept apart in an asset store, which can stream them rather than handling them as whole buffers. Two things live there. One is the spilled values from the Keys chapter. The other is assets: content such as photos that an app stores next to its facts.
Naming by hash has three useful consequences:
- A name can be checked. Whoever hands you a block, you can hash it and see whether it is the block you asked for. Dialog checks every tree node it reads and refuses bytes whose hash does not match. So a node can come from anywhere, including a server that is not trusted, without that server being able to change it.
- Equal blocks are stored once. Two trees that share a subtree share its blocks. Writing a block that already exists is harmless: it can only write the same bytes again.
- A root names a whole tree. A root holds its children’s hashes, which hold theirs. Handing someone one 32-byte root hands them a name for every fact beneath it.
Assets
An app stores a photo by asserting it in a transaction, like a fact. The commit writes the photo’s bytes to the asset store and records one fact about them in the same revision: the dialog.asset/size of the entity asset:{hash} is the number of bytes. That entity is the photo’s name, and the app’s own facts can point at it, such as the demo.grocery/photo of item:2 is asset:….
The fact is what says a branch holds the asset. A push sends the bytes of every asset whose fact is new, a read fetches only bytes that such a fact vouches for, and retracting the fact drops the branch’s hold on the bytes. Only a commit that stores the bytes can write the fact, since it is in a dialog. domain, so the size it states is measured from the bytes themselves.
Two kinds of storage
For its data, a repository keeps two kinds of things:
- The archive holds blocks and assets. It is a map from hash to bytes. Entries are only ever added, and a name always means the same bytes.
- Memory holds cells. A cell is a small named value that can change, like the current head of a branch (see Commits and History). A cell is updated with compare-and-swap: a writer says what it believes the cell holds now, and the update fails if someone else changed it first.
Almost everything is in the archive. A handful of cells point into it.
Where blocks live
The same archive runs on several backends. Tree nodes go in a catalog named index, and a block’s key is its hash written in base58:
| Backend | Where a tree node lives |
|---|---|
| Native file system | {repository's directory}/archive/index/{base58 hash} |
| Browser IndexedDB | object store archive/index, key {base58 hash} |
| Browser file system (OPFS, opt-in) | the same path layout as native |
| S3 or R2 | object {repository DID}/index/{base58 hash} |
| In memory | a map, for tests |
Assets sit beside them: {repository's directory}/blob/{base58 hash} on a file system, and {repository DID}/blob/{base58 hash} on S3 or R2. In a browser, assets always go to OPFS, even when blocks and cells are in IndexedDB.
The file system backend writes each block atomically, and skips the write when the file already exists, since a block with that name can only have those bytes.
Writing: one import per commit
While a commit edits the tree, the new nodes it produces collect in memory, and so do the values it spills. When the commit is sealed, the spilled values are written to the asset store, each checked against its hash, and then every new node is written to the archive in one import. Only after that can the branch’s head move to the new root. A head can therefore never point at a root whose blocks are not yet stored.
Reading: fetch on demand
A replica does not need every block of a tree to use it. When a read reaches a node that is not in the local archive, and the branch has a remote peer, Dialog fetches that one block from the remote peer and keeps a copy:
So a phone can open a large shared repository by fetching its root, then mostly the nodes its queries walk through, plus some that a query fetches ahead of where it is reading. The hash check happens in the tree reader, which is why a remote peer can be any dumb store of bytes. Spilled values are fetched the same way, from the asset store, and checked the same way.
Fetches are scheduled, not fired one by one. Readers that want the same block share one fetch, and each remote peer serves a limited number of requests at a time, with a read that a query is waiting on going ahead of one made on speculation.
Implementations. Hash-checked reads are LoadBlock in dialog-search-tree/src/load.rs for tree nodes, and LoadBlob in dialog-artifacts/src/archive.rs for spilled values. Assets are dialog-repository/src/repository/branch/asset.rs. The local backends are under dialog-storage/src/storage/provider, and the S3 and R2 remote peer is dialog-remote-s3. Fetching from a remote peer on a local miss is NetworkedIndex in dialog-repository/src/repository/archive/networked.rs, and the scheduling is in dialog-peer/src/peer/hydrate.rs.
Commits and History
A tree root names one state of the facts. To go from one state to the next, an app makes a commit. This chapter follows Bob as he checks the eggs off the list, and looks at everything his commit leaves behind.
Two questions drive the design. When Bob’s change reaches Alice, how does her replica know whether it has already seen it? And when Bob asserts that the eggs are done, superseding done false, how does Alice know that her own copy of done false is the one his change removes, and not a newer one she wrote since? Both answers come from giving every change a version.
Who is writing
Every commit is written by someone, on some branch, of some replica. Dialog names each of these with a hash of the level above:
- A replica is one peer’s copy of a repository: the hash of the repository’s DID and the peer’s DID.
- A branch is a named line of work in a replica, like
main: the hash of the replica and the name. - An origin is one writer on one branch: the hash of the branch and the key that signs the commits. That is the peer’s own key when the peer writes as itself, and a session’s key when it writes through a session (see Identity and Capabilities).
An origin writes one commit at a time, in order, never two at once. Everything below depends on that.
Versions
A version is a pair: an origin, and an edition.
The edition counts depth, not writes. A commit’s edition is its parent’s edition plus one. A merge’s edition is the larger of its parents’ editions, plus one. So when Bob pulls Alice’s two commits and then makes his first, his first commit is already edition 2:
Editions give a rough sense of order that every replica agrees on without a clock. Versions compare by edition first, then by origin, so any two versions have a definite order.
What a commit writes
Bob’s commit asserts done true on item:2. The done flag is read under last, so the assertion supersedes the fact such a read returns, done false. Every entry the commit writes carries its version, (oB, 2). Here is all of it:
The fact keys. The new fact goes into EAV, AEV and VAE, as in the Keys chapter. Its payload records the version that wrote it. The old fact, done false, is removed from all three. Had the item held a second done flag that a read did not return, that one would have stayed (see Facts).
A history record. Under tag 03, the commit writes one record for each fact it changed. The record’s key starts with the origin and then the edition, so each writer’s records sit together, in edition order. Its payload lists the versions this change supersedes. Bob’s record says “at (oB, 2), item 2’s done became true, superseding what (oA, 1) wrote.” A retraction writes a record too, marked as a retraction.
A coverage record. A record that removed something, a retraction, or an assertion that superseded at least one earlier version, is copied under tag 05, keyed by version and by the hash of the value rather than the value itself. The coverage region holds nothing but removals, so a replica can list every removal since it last synced without reading the much larger history of assertions. A graft merge (see Sync) relies on exactly that.
A revision record. Last, the commit writes one fact about itself: attribute dialog.db/revision, on an entity derived from the version. Its value is a signed record naming the commit’s parent versions, its issuer, and links further back for fast ancestry walks. So the tree also holds the commit graph.
The fact indexes answer what is true now. The history region answers what happened. Both live in the same tree, under the same root.
The head
The commit then seals the tree and writes the new blocks (see Blocks and Storage). At that point the commit is staged: it exists, but no reader of the branch sees it yet. Publishing it moves the branch’s head. The head is a small signed record in a cell named branch/main/revision. It holds:
- the branch,
- the issuer, the key that signed it,
- the tree root,
- the edition,
- a marker saying its watermark, described below, is in its tree,
- the signature over all of the above.
These are the bytes the signature covers:
The head is updated with compare-and-swap. Bob’s replica says “move the head from the revision I built on to this new one.” If another commit moved it first, the swap fails with a version mismatch and nothing is lost: Bob’s blocks are stored, and the app runs its change again on the new head.
The watermark
The watermark says, for every origin, how far into that origin’s commits this head has seen. It is a map from origin to two numbers: the highest edition seen, and how many of the origin’s commits that covers.
| origin | highest edition | commits seen |
|---|---|---|
oA | 3 | 4 |
oB | 2 | 1 |
Because an origin writes one commit at a time, “everything up to edition 3” is an exact description of what the head has seen from oA. So the question “has this head seen version v?” is one lookup: v is seen when its edition is at most the watermark’s edition for its origin.
A watermark has one row per origin that ever wrote into the history, and rows are never removed: a device that commits once and is never seen again keeps its row. So the watermark is not carried on the head at all. It is written into the tree itself, in a small region of its own with one entry per origin, and the head only says that it is there.
The region skips one row: the head’s own. That row says where the current writer’s run of commits began, and a reader brings it up to date from the head, because each commit in a run adds exactly one edition and one commit. So a writer committing on top of its own head writes nothing to the region. A commit on another writer’s head writes two rows, the other writer’s (its run has ended) and its own, and a merge writes the rows its two sides disagree on.
That one question answers both questions from the start of the chapter. When a change arrives that the receiver has already seen, it can be skipped. And when a fact arrives from another replica that the receiver has seen and no longer holds, the receiver knows it was removed on purpose, not missing, and does not bring it back. Deleted facts need no tombstone in the fact indexes. The Sync chapter shows this in action.
Implementations. Versions, editions, origins and watermarks are in dialog-capability/src/history. The head is Revision in dialog-capability/src/identity/revision.rs. History and revision records are in dialog-artifacts/src/history. The layout of history and coverage keys is in dialog-artifacts/src/key/history.rs. The watermark region is in dialog-artifacts/src/key/context.rs and dialog-artifacts/src/history/region.rs. The commit itself is dialog-repository/src/repository/branch/commit.rs. The design is written up in notes/version-control.md.
Transactions. Every write goes through a transaction. An app opens one on a branch, asserts and retracts facts in it, commits it, and publishes the commit. A transaction can be queried before it is committed, and it answers as the committed result would: a fact that one of its own writes superseded is already gone. An app can also stage several commits one after another, or amend the one it just staged, and publish them all with a single swap of the head, so other readers see all of them or none. That is Transaction in branch/transaction.rs, with the staging and the single swap in transaction/batch.rs.
Identity and Capabilities
So far the grocery list has lived on one device. Before Alice’s commits can reach Bob, they have to be stored somewhere both can reach, and that somewhere has to know who is allowed to write. Dialog has no user accounts on a server and no passwords. Everyone is a key, and permission is something one key signs over to another.
Everyone is a key
Every key Dialog creates is an Ed25519 key pair, named by its public key written as a did:key. The name is built from bytes like this:
The two-byte prefix ed 01 says what kind of key follows. The 34 bytes are written in base58, and prefixed with z to say so. The bytes above become did:key:z6MkgyhVBiwiVdpYtCepzRZFdQcdBNEZZjNBZN6CwyYC97tg. Every Ed25519 did:key starts with z6Mk, because that is what the prefix bytes turn into.
Several kinds of things are keys:
- A repository has its own key. Its DID is the repository’s name: in permissions, on a remote peer’s storage, and in the identities of its replicas and branches.
- An authority is someone responsible for changes: usually a person, like Alice, with a presence across several peers, such as her laptop and her phone. It is the key those peers act for. No peer holds it. The authority’s key is made once, when Alice first sets up, and sealed to the recovery credentials she keeps. Her peers act through permissions the authority signs for them.
- A peer is a place where replicas live, such as Alice’s laptop or Bob’s phone. One peer can hold replicas of many repositories. Its key never leaves it. The key is kept in a credential store of its own, apart from the storage that holds the data, and in a browser it is a non-extractable WebCrypto key, so even the page that uses it cannot read its secret half.
- A session is the same peer acting with a second key, under less. The key is usually derived from the peer’s key for one app or one purpose, and the session acts only under a permission from the peer. It is never handed the peer’s key or a repository’s key, so the app never holds either.
A secret key that has to outlive one device is never stored in the open. It is sealed to another key: encrypted so that only the holder of that key can open it. A key that other secrets are sealed to is called a vault, and an authority is one.
When Alice’s laptop creates the grocery list repository, it makes a fresh key for it. It seals the secret half to Alice, as its authority, and keeps a second copy sealed to itself, so it can sign as the repository without asking for her recovery credentials. The repository’s key then signs one permission, to the authority, allowing everything. Storage is handed only the public half. From then on, the repository is Alice’s because the repository itself said so.
Capabilities
A permission in Dialog is a capability: a signed statement that one key may do something on behalf of another. Dialog uses UCAN for these.
A delegation says “you may do this, as me.” It names an issuer, an audience, a subject, and a command, plus optional conditions and a time window. The subject is the repository the permission is about, or any, which lets the audience act wherever the issuer may. Delegations to devices and sessions are usually of that second kind. The command is a path, and a delegation for a path covers every command under it. A delegation for / covers everything.
An invocation says “do this, for this subject.” It is signed by the key that wants the work done, and it carries the delegations that prove it may.
The commands Dialog sends to a remote peer look like paths:
| Command | What it asks for |
|---|---|
/use/get/archive/block | read a block |
/use/put/archive/block | store a block |
/use/get/memory/cell | read a cell, such as a branch head |
/use/put/memory/cell | update a cell, with compare-and-swap |
/use/delete/memory/cell | remove a cell |
/use/get/archive/blob, /use/put/archive/blob | read or store an asset |
The first part of a command says what kind of thing it does. Everything under /use reads or writes. Destroying something, such as deleting a branch, is under /void, so a delegation for /use does not allow it. More commands exist than a remote peer ever sees: creating and listing branches, or keeping a peer’s contacts, are commands too, performed on the device and checked the same way.
Here is a typical chain that lets the session on Alice’s laptop store a block in the grocery list’s remote peer:
Each delegation’s issuer must be the previous one’s audience, and the first issuer must be the repository itself. The repository’s delegation to the authority names the repository as its subject. The authority’s delegation to the laptop, and the laptop’s to its session, are of the any kind. Bob gets access the same way: someone who holds a delegation for the repository signs a new one to Bob, possibly for a narrower command.
Asking a remote peer
A remote peer that speaks UCAN takes one HTTP request per operation. The signed container goes in the Authorization header and the body is the raw bytes. The command and subject are also copied into the URL, but only as labels for network logs: the remote peer reads them from the signed invocation and ignores the URL:
The remote peer checks, in order:
- The chain links up: each issuer is the previous audience, and the first issuer is the subject.
- The current time falls inside every delegation’s window.
- The command is covered by every delegation in the chain, and the arguments meet their conditions.
- Every signature holds, and no link has been revoked.
- The body is what the invocation promised. For a block, its BLAKE3 hash is the digest the invocation named and its SHA-256 is the checksum. For a cell, its SHA-256 is the checksum. For an asset, its size and digest match.
Only then does the remote peer store the block or answer the read. The remote peer never needs to know who Alice is. It only needs to know the repository’s DID, and to check signatures.
A second exchange exists for storage the remote peer does not proxy. There the remote peer answers an invocation with a presigned S3 URL, a permit, and the client talks to S3 directly. Which exchange to use is part of the remote peer’s address, not negotiated per request.
Revocation. UCAN lets an issuer revoke a delegation it signed, and the verifier in Dialog asks a revocation checker about every link of the chain. The checker Dialog ships today does not consult any store, so it reports every delegation as not revoked. Until a real store is plugged in, a delegation stays valid until its time window ends, and the repository’s delegation to its authority has no window at all.
Implementations. Keys are in dialog-credentials and signatures in dialog-varsig. Capabilities are dialog-capability, and the commands are defined in dialog-effects. UCAN tokens and chain checks are dialog-ucan-core. The HTTP exchange is dialog-remote-ucan. Creating a repository and delegating it to its authority is in dialog-peer/src/peer/space.rs. Vaults and authorities are in dialog-peer/src/peer/secret.rs, sealing in dialog-credentials/src/secret.rs, and the design is written up in notes/peer-and-session.md.
Sync
Alice and Bob each have a replica. Now they need to agree. Dialog does this through a remote peer: a peer that both can reach and that stores blocks and cells, such as an S3 bucket, a Cloudflare R2 bucket behind a UCAN service, or a shared folder. The remote peer stores bytes and swaps cells. It does not understand facts, trees or merges. All of that happens on the replicas.
What a branch syncs with is called an upstream: another branch, usually the same branch on a remote peer. An app names a branch’s upstreams, to pull from, to push to, or both, and the repository records them as facts in a small branch of its own. A remote peer is known by its DID and the addresses it can be reached at, which the device keeps as a contact. A pull or a push that names no upstream goes to all of them.
A branch remembers one extra thing per upstream: its sync base, the tree the upstream’s head pointed at the last time the two agreed.
A whole conversation
Here is everything that happens between Alice’s laptop, the remote peer, and Bob’s phone, from Alice’s first push to the moment both replicas hold the same root. Each message is written as what it says:
The rest of the chapter walks through it.
Push
A push moves the remote peer’s head forward to the local one, and only forward.
- Check. Read the remote peer’s head and check its signature. If it is not the sync base, someone else has pushed since this replica last looked, and the push stops with a refusal: not a fast-forward. The replica must pull first.
- Find what is new. Compare the local tree with the sync base, skipping every subtree whose hash matches, as described in The Search Tree. What is left are the blocks the remote peer does not have.
- Upload. Send spilled values and the bytes of new assets, then the new tree nodes, children before parents. A node never arrives before the nodes it points at, so the remote peer never holds a node with a dangling link.
- Swap the head. Ask the remote peer to set its head cell to the new signed head, if it still holds what step 1 read. If another push slipped in between, the swap fails and nothing points at the uploaded blocks.
- Record. The new tree becomes the sync base.
On S3 the swap in step 4 is a conditional PUT with If-Match on the cell’s ETag, or If-None-Match: * when there is no head yet.
Pull
A pull brings the remote peer’s changes in. It reads the remote peer’s head, checks the head’s signature, and then asks two questions: has the local replica seen the remote peer’s head, and has the remote peer seen the local head? Each watermark says what its side has seen (see Commits and History), and having seen a head means having seen everything that head had seen. The first question is a lookup in the local watermark. The second needs one row of the remote peer’s watermark, which lives in its tree. When both heads were written by the same writer, the editions answer it with no reads, which is the usual case for a device following its upstream. Otherwise the replica reads that row from the remote peer’s tree, a few blocks. The answers tell the replica which case it is in:
| Case | What the replica says | What it does |
|---|---|---|
| The local head has seen everything the remote peer has | “Nothing new here.” | keeps its head, updates the sync base |
| The remote peer has seen everything local, and nothing changed locally since the last sync | “I’ll take yours.” | adopts the remote peer’s root as is, reading at most the blocks that hold one row of its watermark |
| Both sides have changes the other has not seen | “We need to merge.” | merges, then makes a merge commit |
When Bob pulls for the first time, he has no local changes, so he takes Alice’s root as it is. He reads no nodes to do it. The nodes arrive later, on demand, as his queries walk through them (Blocks and Storage).
Merge
When Alice’s push is refused, she pulls and finds that both sides have changes. Her replica has to combine them.
Dialog picks the cheaper direction. It takes the side with fewer changes since the two last agreed, and integrates those changes into the other side’s tree. Here Alice made one commit and Bob made one, so she integrates hers into Bob’s tree. When both sides have more than 8 commits the other has not seen, and they share a sync base, Dialog instead grafts whole subtrees from one tree into the other, which costs less than integrating every change. A grafted subtree can still hold facts the other side removed, so Dialog then lists every removal either side made since the sync base, from the coverage records described in Commits and History, and clears those facts from the merged tree.
Integrating is not blind copying. Every change keeps its temporal information, the version that wrote it, and conflicts are resolved along the way. Unless the two replicas have never seen a single commit from each other’s origins, incoming changes are screened against what the receiving side has seen:
- A fact the receiver has already seen, and no longer holds, stays gone. If Bob had retracted a fact and Alice’s replica still held it, her copy’s version would fall inside Bob’s watermark: Bob saw it, and removed it. It is not brought back. This is how deletes survive a merge without tombstones.
- A removal only removes an entry that matches it exactly, down to the version that wrote it. Alice’s change to the milk’s name removes the name
"Oat milk"written at(oA, 0). If the receiver holds a copy of a fact written by some other version, the removal leaves that copy alone. - A record that superseded something retires what it superseded. Bob’s history record says his
done truesuperseded(oA, 1)’sdone false. After the merge,done falseis gone on both sides, even on a replica that still held it.
History records are merged before facts, so a removal always lands before an incoming fact could contest the slot it clears.
If the merged tree equals one of the two sides, that side’s head is used as it is. Otherwise the replica makes a merge commit: its parents are both heads, its edition is one more than the larger of theirs, and its watermark covers both. Alice’s merge is (oA, 3).
When both change the same thing
Alice renamed the milk to "Oat milk, 1 L". Bob, without seeing that, renamed it to "Soy milk". A name has one value, so each assertion superseded only the "Oat milk" its writer had seen. Neither saw the other’s value, so the merge keeps both:
A name is meant to have one value, so a reader that asks for it under the pick last sees an election between the two. The fact whose commit had seen more wins: the deeper edition. If the editions are equal, as they are here, the hash of the version decides. Every fact a commit wrote carries the same version, so a whole commit wins or loses together: a reader never sees half of Alice’s edit and half of Bob’s.
The election is a function of the stored facts alone, so every replica elects the same name without talking to anyone. The losing value is not deleted. It stays in the store, and an app that wants to show “edited on two devices” can ask for every value by reading the name under all. It stays even after someone asserts a new name, because an assertion supersedes only the fact a read returned, which is the winner. The loser goes when someone retracts it.
The other picks choose in their own ways, max by the greatest value and top by a ranked list, but always from the stored facts alone, and so the same way on every replica.
After the merge
Alice pushes her merge. This time the remote peer’s head is Bob’s, which is her new sync base, so the push goes through. Bob pulls, sees that Alice’s merge has seen everything he has, and adopts its root without reading a single node. The two replicas now hold the same root, and they agree on every fact.
Implementations. Push is branch/push.rs and pull is branch/pull.rs, both in dialog-repository. The screens are documented and implemented in dialog-artifacts/src/merge.rs. The election is ArtifactView::elect in artifacts/artifact.rs. Naming and resolving upstreams is branch/set_upstream.rs and branch/upstream.rs. The remote peers are dialog-remote-s3, dialog-remote-ucan, dialog-remote-ucan-s3 and dialog-remote-fs.
Queries
Everything so far has been about getting facts stored and shared. This chapter is about getting them back out. Bob’s phone wants to show the grocery list: every item, its name, and whether it is done.
Dialog’s query language is in the family of Datalog. A query is a set of patterns with blanks in them, and the answer is every way of filling the blanks from the facts.
Patterns over facts
The smallest pattern is a single fact with blanks. The grocery name of ?item is ?name matches every name fact, and fills ?item and ?name from each one. Add a second pattern that shares a blank, the grocery done of ?item is ?done, and the two must agree on ?item. The answer is one row per item, with its name and its done flag.
An app rarely writes patterns one at a time. It declares a concept: a named shape made of attributes. In Rust, attributes and concepts are types:
mod grocery {
/// The name of a grocery item.
#[derive(dialog_query::Attribute, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[domain("demo.grocery")]
pub struct Name(pub String);
/// Whether the item has been picked up.
#[derive(dialog_query::Attribute, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[domain("demo.grocery")]
pub struct Done(pub bool);
}
#[derive(Concept, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Item {
pub this: Entity,
pub name: grocery::Name,
pub done: grocery::Done,
}
The domain comes from #[domain] and the name from the type, kebab-cased, so grocery::Name is an attribute over the relation demo.grocery/name. The doc comment becomes its description, the wrapped type its value type, and it reads one value, the newest, unless it says another pick such as #[pick(all)]. A wrapped String declares text: and a bool declares boolean:. A Rust number declares the number type of its own width, so a u8 declares natural:8. Each field of the concept is one pattern, and this is the entity they share. Writing an Item asserts its facts. Querying for Item with blanks in every field asks for every item:
use dialog_query::query::Output;
let items: Vec<Item> = branch
.query()
.select(Query::<Item> {
this: Term::var("this"),
name: Term::var("name"),
done: Term::var("done"),
})
.perform(&operator)
.try_vec()
.await?;
A concept is a way of reading facts, not a table. The facts do not know which concepts they belong to. Any entity with a demo.grocery/name and a demo.grocery/done is an Item, and a second app can declare its own concept over the same relations without asking the first.
Attributes and concepts have identities of their own. An attribute is named by a hash of what it means: the relation it reads, its type and its pick. A concept is named by a hash of the attributes its fields hold, and whether each is optional. Descriptions and field names are left out, so rewording a description or renaming a field changes no name. The names are entities, written the:… and concept:…, so two apps that declare the same attribute have declared the same thing.
Planning
Before running a query, Dialog decides how to match its patterns. Each pattern has a cost, and the cost depends on which of its parts are already known. A pattern with only its attribute known has to scan a whole run of AEV. A pattern whose entity is known is a short lookup in EAV. At each step, the planner picks the cheapest pattern it can run given the blanks filled so far.
Which index a pattern reads follows directly from the Keys chapter: if the entity is known, EAV; if not, but the value is, VAE; otherwise AEV. For the grocery list, both patterns know only their attribute, so both are AEV scans. AEV sorts each attribute’s run by entity, so Dialog walks the two runs side by side and joins them on the entity, like a zipper:
When one pattern is much narrower than the other, say done pinned to false, the planner drives from that pattern instead and looks up each row’s other fields in EAV. Either way, every step is a range scan over sorted keys, streamed. A query never loads a whole index. On a phone that has fetched only part of the tree, a query fetches only the nodes along the ranges it reads: its patterns’ ranges, plus the range where rules are stored.
Picks
An attribute is declared with a pick, which says which of an entity’s facts a read returns (see Facts). demo.grocery/name reads under last, which is the default, and demo.grocery/tag could be declared with #[pick(all)]. Under last, a query that finds two values runs the election from the Sync chapter and returns one. Every replica elects the same value. Under all, a query returns every value.
The other picks also return one value. max and min return the greatest and the least. top takes a list, of values or of relations, and returns the first on the list that the entity has: a status that is "urgent" if any fact says so, or a display name that falls back to a username.
A number is compared the way its attribute’s type reads it (see Facts). A query for a quantity of 2 through an attribute declared natural: also finds a stored 2.5, because that is what a reader of the attribute sees there.
Optional fields
A concept field can be optional: quantity: Option<grocery::Quantity>. An item without a quantity still matches, and the field reports that the value is absent. Storage never records that an attribute has no value (see Facts). The query infers it from the fact not being there.
Rules
A rule derives facts from other facts. An app could say an item is urgent if it is tagged "breakfast" and not done, and then query for urgent items as if urgent were stored.
What a rule derives are facts in relations, like the ones an app asserts. A rule that concludes a concept derives one fact for each of the concept’s fields. So any concept that reads one of those relations sees the derived values next to the stored ones, not only the concept the rule was written for.
Rules are installed by asserting them, and they are stored as facts in the tree, under dialog.rule/. So a rule syncs with the data like everything else. When Bob’s phone pulls Alice’s rule, his queries start using it.
A rule is named by a hash of what it means, not of the bytes its author wrote. Before hashing, its variables are renamed by where they appear and its patterns are put in order, and the concepts it uses enter by their own names. Two people who write the same rule with different variable names install the same entity. That canonical form is also what gets stored, so the names an author chose are not kept.
Most rules run at query time. A rule can also run when a commit is made, and write its conclusions into the tree, which suits derived data that is read far more often than it changes.
Rules that say “not”
The urgent rule asks for something to be absent: the item is not done. A rule can say that with unless, and an optional field or a pick like top asks the same kind of question, since “the best one” means “this one, and nothing better”.
Absence is only well defined once everything that could be there has been derived. Usually that is easy: Dialog derives what a rule tests in full before running the rule. It stops being possible when rules form a loop through such a test, where a rule tests for the absence of something that depends on the rule’s own conclusion. No single author has to write that loop. Alice and Bob can each install rules that are fine alone and close a loop when their replicas merge.
Dialog does not refuse such a set of rules, because rules arrive by sync and something has to be done with whatever arrives. It sets one rule of the loop aside instead, and evaluates the rest. It picks the rule installed last, ordered by the commits that installed them, which every replica sees the same way. So replicas with the same history set aside the same rule and agree on every answer. A rule that is set aside comes back by itself once the loop is broken, and a query can ask which rules are set aside by reading dialog.rule/quarantined.
Subscriptions
A list on screen should update when the data does. A subscription keeps a query live. It remembers which key ranges the last evaluation read. Each time the app polls it after the branch’s head moves, it compares the old and new trees, again skipping every subtree whose hash matches, and looks at whether any change falls inside those ranges. If none does, the list stays as it is and nothing is re-run. If some do, it re-derives only the entities that changed when the query allows it, and re-runs the whole query when a rule changed. The app receives what was added and what was removed.
So when Alice’s merge arrives on Bob’s phone, the diff between the two roots is small, the list’s ranges catch the renamed milk, and one row updates.
Implementations. The query engine is dialog-query: concepts in src/concept, the planner in src/planner.rs, rules in src/rule.rs, and the identities of attributes, concepts and rules in src/identity.rs and src/rule/canonical.rs, built on merkle-reference. The attribute and concept derives are in dialog-macros. Choosing an index for a pattern is selector_range in dialog-artifacts/src/tree.rs. Subscriptions are branch/subscription.rs in dialog-repository.
Looking Ahead
That is the whole trip. A grocery item started as a sentence, became three sorted keys, landed in a leaf chosen by coin flips, sat in a block named by its hash, was recorded in a signed history, crossed to another device under a chain of signed permissions, was merged with a concurrent edit, and came back out as a row in a query.
Dialog is experimental, and several of the pieces above are still moving. These are the ones most likely to change what this guide says.
The node layout is a draft. Every assigned code in the code table is marked draft. The layout could still change before a stable release. A change to the layout would get a new version byte, and a new setting gets a new code; neither reinterprets old nodes silently. Whether the table stays a CSV that the code is checked against, or becomes a schema the code is generated from, is also open.
Numbers of any size. A key can already hold a number of any size, and so can a number’s identity. The values an app hands to Dialog cannot yet: they are 128-bit integers and 64-bit floats, so a wider number stored by someone else reads back as the nearest float. A value that holds any number is planned. It changes no key, so nothing stored will need rewriting for it.
Encryption. Nodes are stored in the clear today, so a remote peer can read what it stores even though it cannot change it. The node prelude leaves room for encrypted bodies as a future layout version.
A level in every node. A node does not say how deep it sits in its tree. Sync and diffing could use that to plan their work without descending, and it would be one more byte in the prelude, under a new version.
Revocation. Delegations can be revoked in UCAN, and Dialog’s verifier asks about every link, but no revocation store is wired in yet. Until one is, a delegation lasts until its time window, if it has one, ends.
This is the end of the guide. The design notes behind each chapter are in the notes directory of the repository, and the code is in rust. If something here disagrees with the code, the code is right, and a pull request to this guide is welcome.