Records step 10's decisions with their reasons and the alternatives weighed: a list of parents instead of ADR 0001's single parentRevisionId (a revision with two parents resolves a conflict), a three-way merge per side against the newest common ancestor (no silent merge inside a side), and an explicit fractional position for the order. ADR 0001 gets an "Amended by" line, and AGENTS.md points to the new ADR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
4 KiB
ADR 0003: Card revisions have a list of parents and merge per side
- Status: accepted
- Date: 2026-09-30
- Amends: ADR 0001, whose rule 2 gave each revision a single
parentRevisionId.
Context
ADR 0001 keeps card content as append-only revisions, so that edits made on several devices while offline are never lost: "two revisions with the same parent are a visible conflict". Building the cards (step 10) raised three questions the rule left open: how a conflict is resolved in the revision graph, how finely concurrent edits merge, and how cards are ordered. All three had to be answered before the tables existed, because every migration of a synced table locks older app versions out of syncing (ADR 0002, rule 3).
Merging per character, as a CRDT like Automerge does, was weighed on 2026-09-29. It only pays off for live co-editing. For short content a silent merge is worse than a visible conflict: "big" changed to "large" on one device and to "huge" on another becomes "largehuge", and nothing reports a conflict inside the text.
Decision
- A list of parents.
CardRevision.parentsholds the ids of the revisions it replaces. A card's heads are the revisions no other revision names as a parent. Saving a card appends a revision whose parents are all its current heads, so after a conflict that revision is the resolution. - Three-way merge per side. Several heads merge side by side (front, back) against
their newest common ancestor: of the common ancestors, one that no other common
ancestor descends from, and the newest of those after criss-cross merges. A side one
head changed takes that change, a side changed the same way on several devices is no
conflict, and a side changed differently is a conflict that the app shows with every
version until the person keeps one. Merging happens when reading; nothing is written
until the person saves. The code is
halkyon_learn_flutter/lib/src/feature/card/model/card_merge.dart. - An explicit position.
Card.positionis a fractional index (rocicorp's algorithm, throughfractional_indexing_dart, checked against the algorithm's reference test vectors) in a column of its own, where the last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
Consequences
- No edit is lost, and none is merged silently inside a side.
- Every device computes the same merge from the same revisions. Two devices resolving the same conflict at once create two heads; if they chose the same content, those merge without a conflict, otherwise the conflict shows again.
- Clock skew between devices can make "newest" pick an older common ancestor. Then more sides count as changed, which can add conflicts but never loses an edit.
- Moving a card writes only its position; if two devices move the same card, the later move wins.
- Revisions accumulate. Compaction (blanking old payloads, the mechanism of ADR 0002, rule 2) comes with shared spaces, where most edits will happen.
Alternatives considered
- One parent (
parentRevisionId, ADR 0001 as written): resolving a conflict would need an extra mechanism, such as marking the other head as superseded. - Whole-card conflicts: every concurrent edit would be a conflict, even when the devices changed different sides.
- Merging per block now: needs stable block ids in the rich content, which
appflowy_editordoesn't persist, so a new content format, before any long-form content exists. It remains possible later for lessons, as app code over the same tables. - Merging per character (a CRDT such as Automerge): see Context.
- Creation order instead of a position: no reordering, and UUID v7 ids aren't guaranteed to keep their order within one millisecond, as in a bulk import.
Way back
The merge is app code: a finer one (per block, for long content) can replace it without
touching the tables. Changing parents or position would be a migration of a synced
table.