docs: ADR 0003, card revisions with a list of parents, merged per side

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 15:08:27 +02:00
commit ec246e552d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 75 additions and 1 deletions

View file

@ -117,7 +117,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows. - **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read. - **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced. - **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001, rule 2; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes. - **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id. - **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.

View file

@ -9,6 +9,9 @@
longer holds. The decision and its rules stand. Deleted content is redacted before longer holds. The decision and its rules stand. Deleted content is redacted before
deletion (ADR 0002, rule 2), and how authorship is secured in shared spaces is decided deletion (ADR 0002, rule 2), and how authorship is secured in shared spaces is decided
with shared spaces (ADR 0002, *Consequences*). with shared spaces (ADR 0002, *Consequences*).
- **Amended by:** [ADR 0003](0003-card-revisions.md) (2026-09-30): rule 2's revisions have a
list of `parents` instead of `parentRevisionId` and merge per side (front, back); cards
have a fractional `position`.
## Context ## Context

View file

@ -0,0 +1,71 @@
# ADR 0003: Card revisions have a list of parents and merge per side
- **Status:** accepted
- **Date:** 2026-09-30
- **Amends:** [ADR 0001](0001-offline-sync.md), 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
1. **A list of parents.** `CardRevision.parents` holds 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.
2. **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`.
3. **An explicit position.** `Card.position` is a fractional index (rocicorp's
algorithm, through `fractional_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_editor` doesn'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.