Local data
A coven store is one SQLite database on the device, plus the files its rows carry. The host opens it through one handle, declares which tables participate in sync, and runs ordinary SQL. This page is about that declaration: what a synced table looks like, what stays local, and how a gate keeps chosen rows on one device even inside a synced table.
What a synced table looks like
For a row to travel, every device needs two things it can rely on: a stable way to say which row this is, and a value that orders concurrent edits to it. Both ride on typed columns, so a third convention pins the types themselves. These are the conventions a synced table carries, checked at open:
- declared
STRICT: SQLite refuses an insert or update whose value doesn't match a column's declared type, so a synced table can never hold a value off the type every peer's apply and coven's own conflict-resolution code expect - a
TEXTprimary key namedid, at column position 0: together with the table name, this is the logical row identity on every device - an
_updated_at TEXT NOT NULLcolumn, stamped throughSqlContext::stamp: the register concurrent edits are ordered by
Everything else about the schema is yours. Tables you do not pass to synced_tables never leave the device: that is also the mechanism for per-device state (window positions, per-device pin bookkeeping, local paths). Put it in a table you don't declare.
Row identity
Every SyncedTable declares what its ids mean. Use RowIdentity::IndependentUuid for rows created independently on offline devices; every id must be a canonical lowercase hyphenated UUIDv4 or UUIDv7. Use RowIdentity::SharedKey only when an application key intentionally names the same logical row everywhere, such as settings(id = 'preferences'). Equal shared keys merge as one row under the normal _updated_at policy.
Changing a primary key removes the old identity and inserts the new identity in one transaction; SQLite records that the same way as an explicit delete plus insert. The introduced id must satisfy the table's mode. Even an equal, valid UUID means one logical row: identity mode prevents predictable key reuse, but equal (table, id) values always denote the same row.
Declaring the set
By default, every row of a synced table reaches every device in the store, and some rows are not meant to: a draft, a private list. If keeping them back were application logic, every query and every sync path would have to remember it, and the first one that forgot would leak the row. Instead the host gates the table: privacy becomes a property of the schema, and coven enforces it everywhere a row can travel. The host declares the gate per table on the SyncedTable values it passes to Coven::builder(config).write_policy(...).synced_tables(...), and coven enforces it on both paths a row can take to another device: the per-cycle changeset and the bootstrap snapshot.
Examples: a workspace holds lists, a list holds todos, and a list has a boolean shared column. A private list, and the todos under it, should stay on the device that made it.
SyncedTable has four gate forms:
SyncedTable::new("todos", RowIdentity::IndependentUuid) // no gate of its own
SyncedTable::new("attachments", RowIdentity::IndependentUuid).remote_root()
SyncedTable::new("lists", RowIdentity::IndependentUuid).gated_by("shared")
SyncedTable::new("workspaces", RowIdentity::IndependentUuid).gated_by_descendants()new(name, row_identity)declares the table synced with no gate of its own. With a foreign key into a gated root it inherits that gate; without one it syncs unconditionally.remote_root()keeps whole-table row sync and makes blobs on those rows and their descendants Remote by construction.gated_by(column)makes the table a gated root: a row syncs only while its booleancolumnis true.gated_by_descendants()marks an ancestor that should sync only while a gated descendant of it survives.
A table is one of the four, never two at once. Two further properties are orthogonal to the gate and covered in Blobs: a table may carry a blob (carries_blob), and it may be an asset, a decoration like a cover image that rides its subject's gate but never keeps that subject alive.
One tree, both directions
The gate flows down foreign keys; the keep flows up them.
Gated roots
A gated root carries a boolean column. A row whose column is false stays on the device that wrote it, and its foreign-key descendants stay with it. With lists.gated_by("shared"), a private list and its todos never leave the device.
The gate flows down foreign keys: a child row syncs iff the row at the top of its foreign-key chain, the gated root, syncs. todos reference lists, so a todo is shared exactly while its list is. The host declares the gate once, on the root; coven follows the schema's foreign keys to every descendant, so the children need no declaration of their own.
Remote roots
A remote root has no gate column. Every row syncs, like new(name, row_identity), but the row also anchors blob locality: blobs carried by that row or by descendants are Remote. Host-provided blobs upload before the row changeset is pushed, and a peer reads them from the cache or cloud. make_remote, make_local, and cancel_make_remote reject a remote root because there is no Local state to enter or leave.
A plain table that carries blobs but is not under a gated root or remote root is not a blob locality root. Rows still sync, but blob reads fail because coven has no authoritative Local/Remote answer for the bytes.
Ancestors
A list belongs to a workspace. A workspace sits above the gate, not below it, so the root gate never reaches it: a workspace whose every list is private would still sync its own row and arrive on a peer as an empty workspace, a row pointing at nothing.
gated_by_descendants() removes that orphan. The ancestor syncs only while a surviving descendant references it. coven infers which descendants count from the foreign-key graph: every synced table with a foreign key into the ancestor, except a child that already inherits the ancestor's own gate (a many-to-many join row, which would otherwise keep its parent alive in a circle). The rule composes up the chain, so a workspace nested in a parent of its own would sync only while a surviving workspace, and through it a shared list, kept it alive.
A many-to-many is the case the exception covers. Say todos carry labels through a todo_labels join. Mark labels with gated_by_descendants(): a label syncs while a shared todo wears it. The todo_labels join row inherits the list's gate downward (it is a descendant of todos), so it does not count as a keep-child of todos; it does count for labels.
The keep rule
A private row must not leak through any channel, and there are two: the per-cycle changeset and the bootstrap snapshot. If each path had its own idea of "private", they would eventually disagree, and the disagreement would be a leak. So both share one definition of "kept", built as a SQL predicate. For a gated root it is the column test:
lists.shared IS NOT NULL AND CAST(lists.shared AS INTEGER) <> 0For a descendant it is an EXISTS up the foreign key into its parent's rule:
EXISTS (SELECT 1 FROM lists
WHERE lists.id = todos.list_id AND (<lists kept>))For an ancestor it is an EXISTS down each inferred child into that child's rule:
EXISTS (SELECT 1 FROM lists
WHERE lists.workspace_id = workspaces.id AND (<lists kept>))Every form bottoms out at some root's column test, so "kept" is one expression that the changeset filter and the snapshot cleanup evaluate the same way.
Flipping the gate
The gate is a live switch, not a create-time choice, and both directions must move complete subtrees: sharing delivers the whole subtree, and unsharing removes it from peers without destroying the owner's copy.
Setting a root's gate from false to true makes a previously-local subtree public. Peers never held it, so coven re-emits the whole now-visible connected component (the root row, its descendants, and the ancestors the new row keeps alive) as full inserts in that cycle. The subtree lands complete, not as an update to rows the peer is missing.
The reverse, true to false, is a retract: coven emits deletes for the rows that leave the shared set so peers remove them, the mirror of the false-to-true re-emit. The candidate rows are the structural connected component of the roots that flipped this cycle, minus any row still kept by another root (a sibling that shares an ancestor is spared; a now-childless ancestor is deleted too). The flipping device keeps its own rows (now gated-false, local-only): retract writes only to the outgoing changeset, never deletes locally, and fires once on the flip cycle. A root that was never shared has nothing on peers to retract, so it emits nothing.
Where it runs
The two paths a row can cross on both apply the same keep rule:
- The changeset filter cuts gated-false rows from each outgoing changeset before it is signed and uploaded.
- The snapshot cleanup deletes gated-false rows from the bootstrap copy before it is encrypted and uploaded.
So a device that bootstraps from a snapshot and a device that applies live changesets receive the same set of rows.