Private and shared content is stored in plaintext on the instance and is readable by its operator, as on Mastodon. Private sharing happens only between persons of the same instance; only public content travels between instances. The key management that encryption needed (master key, recovery phrase, per-space keys, re-wrapping, compaction on devices only, signed operations) was the largest subsystem on the plan, and it shut the server out of grammar checking, search and publishing. Whoever trusts no operator can run an instance. Rules: the privacy page and a notice when sharing say so; deleting content first blanks the payloads of its revisions, because the sync engine keeps rows until their space is deleted; content stays in the versioned payload, because every migration of a synced table locks older app versions out of syncing; server features decode payloads into server-only tables; reports and suggestions go through endpoints, so the author comes from the session. keyId and nonce stay nullable as the one way back. ADR 0001 gets an "Amended by" note; its decision and rules stand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
6.6 KiB
ADR 0001: Private and shared data sync with serverpod_offline_sync
- Status: accepted
- Date: 2026-09-27
- Evidence: branch
spike/offline-sync(never merged), tests inhalkyon_learn_server/test/spike/, run against a live server on Serverpod 4.0.2. - Amended by: ADR 0002 (2026-09-29): there is no end-to-end encryption, so the premise below that the server syncs data it cannot read no 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 with shared spaces (ADR 0002, Consequences).
Context
Halkyon Learn is offline-first: a learner studies and edits on several devices, offline if need be, and the devices converge. Private and shared studysets are end-to-end encrypted later, so the server must be able to sync data it cannot read. Public studysets are a separate tier (plaintext, published, federated) and are not part of this decision.
serverpod_offline_sync (0.0.8, publisher serverpod.dev, BSD-3-Clause) is a
delta-CRDT engine for Serverpod: the same code runs over SQLite on each device
and PostgreSQL on the server, models opt in with database: sync, conflicts are
resolved per field by hybrid logical clocks (last writer wins), and data is
isolated in spaces (a personal one per person, shared ones created on the
server).
The alternative was plain endpoints plus a local layer of our own: an ordered
change log with a monotonic sequence number and tombstones. It is not a
timestamp cursor (updated_at > lastPulledAt), because PostgreSQL's now() is
the transaction's start time and a row committed late would be missed for
good.
Decision
Use serverpod_offline_sync for private and shared data. Every synced model
is database: sync.
What the spike established
| Question | Result |
|---|---|
| Runs on our Serverpod? | Yes, on 4.0.2 with dependency_overrides for serverpod, serverpod_client, serverpod_database, serverpod_serialization; the package pins 4.0.0 exactly, but serverpod_database 4.0.0 and 4.0.2 are identical. A real round trip between two devices works. |
| References to server-only (public) content | No foreign key possible (rejected at generate time); a plain UUID column syncs unchanged. |
| Blobs (the content envelope) | Byte-identical up to 5 MB (locally 5 MB: 2.3 s up, 3.8 s down). The 512 KB request limit doesn't apply to sync streams. |
| Concurrent edits | The same field: the later edit wins, never corrupted. Different fields: both survive, only with column-scoped updates. |
| Change signal for the UI | onMergeSuccess fires once per merge, never when nothing is new. The engine owns the local SQLite database (no Drift, no reactive queries). |
bool fields |
Survive update round trips (the bug fixed in 0.0.7). |
| Persons and spaces | The personal space is keyed on AuthUser.id. Rows forged into another person's space never reach that person. Access to another space is granted, rows never move. Deleting a space removes every row and its change history. |
| Authentication | Syncs with server-side sessions; without a session, 401. |
Rules that follow
- Updates name their columns (
updateRow(..., columns: (t) => [...])). A full-row update rewrites every field, and the later full-row edit then wins every field. Repositories are responsible for this. - Content is append-only. Card content is stored as revision rows
(
CardRevision:cardId,parentRevisionId,authorId, optionalreportId,payload), never updated in place. Inserts never conflict, so no edit is lost; two revisions with the same parent are a visible conflict. The review log is append-only too. Plain last-writer-wins fields are only for values where losing one of two concurrent edits doesn't matter (a title). - No Drift. Repositories expose
Stream<List<T>>by re-querying after their own writes and ononMergeSuccess. - Public content is referenced softly: a UUID column without a foreign key, globally qualified by its origin. Public content is never physically deleted, only withdrawn. Public tables use UUID keys.
- Rows never move between spaces, and the same UUID in two spaces stops sync
for good. Copies get new UUIDs; merging two persons means granting the
surviving person access to the other person's space. Writes into a shared
space name it explicitly (
transactionForUser(..., spaceId: ...)). - Account deletion and pruning run in a fixed order: revoke all sessions → delete the space → the app wipes its local database. A device with a valid session otherwise re-uploads everything.
- Order by the UUID v7 id, not by a time field alone: timestamps come back at millisecond precision.
- Schema rules (enforced at generate time): unique indexes include
spaceIdand oneString, UUID or nullable field; non-optional relations aredeferred; 1:1 relations are optional; relations only between synced tables. - Sync tests run against a separately started server: client and server engines can't share one process.
Consequences
- Per-field last-writer-wins is acceptable for one person's devices, given rule 2. It is not enough for shared spaces with several editors: those need signed append-only operations on top of the engine (a later ADR).
- Deleted rows stay in their tables, hidden, until the whole space is deleted. Without end-to-end encryption, a deleted card's content therefore remains on the server; the privacy page says so. With encryption, it remains as ciphertext.
- Roles are per space (
readOnly,readWrite), not per row; reports and authorship in shared spaces need their own design, including signatures. - Storage grows by roughly 60–300 % for the change history (the package's own benchmark).
Risks
- Maturity: version 0.0.x, "not yet ready for production use", mostly one
maintainer, and
database: syncis an experimental Serverpod feature. - Version lockstep: every Serverpod upgrade waits for the package or needs
dependency_overrides. - Silent rejection: a device whose writes are rejected (e.g. into a space it has no access to) gets no error.
- Not yet tested: continuous sync over long periods, unique-constraint conflicts, delete-versus-update conflicts, thousands of rows.
Way out
If the package stalls or fails one of the untested cases: plain endpoints plus our own local layer (the alternative above). Rule 3 and the gateway/repository architecture keep that change inside the data layer: features depend on repositories, never on the sync engine.