Merge
Two devices edit the same store while apart; eventually both changesets apply everywhere, and every device must land on the same rows. This page is the semantics of that landing: the clock that orders edits, the column-level merge, and what wins when edits truly collide.
How changesets travel between devices is the Sync page; this one starts where a changeset is already in hand. Examples: Alice and Bob share the todos store.
The clock
Wall clocks cannot order edits: on a laptop a few minutes behind a phone, a correction would sort as older than the edit it corrects, and lose to it. The ordering has to survive clocks that drift, sit offline for weeks, or lie, and it must preserve one guarantee above all: if you pull my edit and then change it, your change wins. A hybrid logical clock provides exactly that.
_updated_at is a hybrid logical clock stamp, not wall-clock time. The host must treat it as opaque: bind the string coven hands it into the row and never parse or compare it as a date. Its format, internal to coven, is {millis:013}-{counter:04}-{device_id}, for example 1735689600000-0000-alice. The three parts make the string sort lexicographically in causal order: a fixed-width millisecond field, then a counter that breaks same-millisecond ties on one device, then the device id that breaks ties across devices.
The clock is an Hlc. Hlc::now mints the next stamp: if wall-clock millis moved forward it adopts them and resets the counter, otherwise it bumps the counter, so each stamp is strictly greater than the last. The host never calls this directly. It calls sql.stamp() inside handle.write, binding the result into every synced-row write. The SQL context and the sync layer share one Arc<Hlc>.
The handle open path seeds that clock before it returns, so every stamp minted through the handle is already past every value on disk. The floor is max(persisted high-water mark, max(_updated_at) scanned across every synced table), so a restart cannot mint a stamp behind a value already written. The on-disk scan is the authoritative source: the high-water mark is flushed only at cycle end and lags any local row stamp minted between cycles.
Advancing past pulled rows
As each changeset applies, the cycle takes the greatest _updated_at among its applied rows and calls advance_past, so an edit made between two applies already sorts after the rows the first apply landed. The next local stamp then sorts strictly after everything pulled so far: pull, then edit, and the edit wins.
The advance is bounded the same way arbitration is (below): a stamp the arbiter refused as grossly future never ratchets the clock either, because only applied rows feed the advance.
Concretely: Alice creates a todo at her 12:00:00, stamped ...-alice. Bob pulls it; his clock advances past Alice's stamp. Bob edits the same todo five seconds later. Even if Bob's wall clock were behind Alice's, his stamp is seeded past hers, so it is lexicographically greater. His changeset reaches Alice, her pull applies it, and his edit wins. Both devices converge on Bob's version: pull-then-edit wins, whatever the wall clocks say.
Which rows merge
(table, id) is the logical row identity across every device. Equal ids in one table therefore select one row and enter the merge below. Tables whose rows are created independently declare RowIdentity::IndependentUuid and accept only canonical UUIDv4 or UUIDv7 ids. Tables with application keys that intentionally name shared state declare RowIdentity::SharedKey; equal keys then merge as one row under _updated_at.
A primary-key change removes the old identity and inserts the new identity; SQLite records it the same way as an explicit delete plus insert. The introduced id must satisfy the table's mode. A valid UUID collision still means one logical row, because equality of the identifier is the identity rule; UUID mode prevents predictable key reuse rather than changing equality semantics.
The merge
Two devices edit while apart; both changesets eventually apply everywhere. Merge runs in two stages inside apply.
Stage one: column-level three-way premerge. An UPDATE changeset carries, per column it changed, the value it moved from (the base) and the value it moved to. When an incoming update loses row arbitration, the premerge rescues its column edits: any column the update moved away from a base value the local row still holds is folded into the local row. The local device never touched that column, so the incoming edit to it survives. When the incoming update wins, it only writes the columns it changed in the first place. Either way, concurrent edits to different columns of one row both land.
Stage two: row arbitration. For every collision the premerge did not fold in, arbitrate_row_conflict compares the two _updated_at stamps and the later writer wins. Concurrent edits to the same column therefore resolve to the later stamp. The _updated_at column index is read from PRAGMA table_info at apply time, so adding columns to the end of a table stays safe.
Two special cases:
- Deletes are remove-wins. A hard delete carries only the row's pre-delete stamp and cannot be reconstructed from a later partial update, so an incoming delete always wins, and an incoming update targeting a locally deleted row is dropped. The row stays gone.
- Grossly-future stamps are refused. A member is trusted, so arbitration is robustness, not a security boundary; still, a buggy client or broken clock could stamp a row far in the future and win every conflict forever. The receiver bounds an incoming stamp to its own wall clock plus an offline allowance (
MAX_FUTURE_SKEW_MS, 30 days) and refuses to let a grossly-future stamp win or ratchet its clock.
Blob content
A declared blob's id, plaintext size, plaintext hash, and optional cloud path merge as one group. When both devices change that group, the later edit wins with its complete set of values. An earlier blob edit survives a later metadata edit when the metadata writer left the blob unchanged. Other columns still merge independently, so changing a caption does not discard a concurrent file replacement.
Capture includes the group's complete old and new values whenever any member changes, including values that stayed equal. This prevents merging one file's id and hash with another file's size. Metadata-only updates leave the group absent from their changeset. The same rules apply during ordinary pull and snapshot rebase.
Constraints and foreign keys
A commit waits until its exact predecessor and dependencies are materialized. If its resulting rows still lack a required foreign-key parent, replay rolls back that commit's application and keeps it pending while other ready commits make progress. If no progress can satisfy the dependency, the commit remains held or reconstruction fails. Its materialized position does not advance.
A non-foreign-key constraint conflict, such as a uniqueness violation or a CHECK failure, rejects the application and reports the affected tables. Installation preserves the previous state rather than committing the rows that happened to pass. Snapshot reconstruction and replay of the retained local suffix share this atomic failure boundary.
Rebasing recorded edits
When a snapshot retires an unpublished write's shared base, Coven reapplies the write's captured row changes against the accepted state through the same merge used for ordinary apply. Disjoint column edits survive, same-column edits follow their captured timestamps, and deletes win over concurrent updates. Equal row identities enter that same merge. A declared SQLite CHECK, UNIQUE, NOT NULL, or foreign-key violation still produces a typed conflict, as does a private edit colliding with accepted shared state or an invalid captured Circle context. Failure rolls back the whole rebase and retains the unresolved write and its dependent suffix.
The retained input is the transaction's net row changes, including changes made by application triggers to synced tables. It does not retain the original statements, their grouping, or assignments that left a column unchanged. Rebase preserves those effects and their original timestamps without running the application triggers again. It replays dependent local writes in their captured order; another snapshot or database reopen does not make an earlier edit newer. Publication retains the captured operations even when merging omits their effect on the current rows. A captured audit row keeps its captured values; it is not generated a second time using newer peer data. Ordinary host writes continue to execute their triggers.
A trigger's RAISE condition checks execution of a host statement. It is not a declarative constraint on every state obtained by combining recorded edits. For example, a trigger rejecting length(title) + length(body) > 10 does not prevent two separately valid column edits from exceeding that limit on rebase. Declare that row invariant as a SQLite CHECK when it must also reject the combined state. Coven does not infer post-state validators from trigger bodies or rerun arbitrary application commands during rebase.
Installing the reconstructed state also derives foreign-key actions on the device's existing local descendants. Those are new local row effects, not trigger effects captured on another device. Their installation does not run application triggers either: a local audit or validation trigger will not execute for a derived cascade. Declared SQLite constraints still apply to the result, and trigger settings are restored before subsequent host commands.