SPEC-0011 Design: Device sync
Deprecated. This design was retired with SPEC-0011 before full implementation (#158); the shipped design is SPEC-0014 (Syncthing-based device sync) per π ADR-0021.
Contextβ
SPEC-0011 implements π ADR-0018: multi-device msgbrowse via archive synchronization, not database replication. The constraints that shape everything here:
- Only the Mac can run the exporters (Signal Desktop key, Full Disk Access, phone backups β π ADR-0015/π ADR-0016), so per source there is exactly one producer. Sync is distribution, not reconciliation.
- Archives are read-only sources of truth and the DB is derived, idempotently re-ingestable state (SPEC-0001) β the sync primitive ("copy files, re-ingest") already exists conceptually; this design makes it safe over a network.
- This is the first listener beyond loopback in an app whose security posture
(π ADR-0010) is built on never having one. The core stays pure-Go
CGO_ENABLED=0(π ADR-0013) β everything below is standard library TLS plus pure-Go dependencies. - Media must travel with messages: the iMessage absolute-path saga showed what a text-without-media archive feels like, and it is the failure mode this feature exists to avoid.
Expected touchpoints: a new sync package, settings-page additions in
internal/web (the shared /settings Connect page β
SPEC-0010 (desktop shell) β which renders this
spec's pairing QR and surfaces sync status), CLI commands, a small store
migration for sync-state tables, and doctor checks.
Goals / Non-Goalsβ
Goalsβ
- Pair a replica with an importer in one physical action (scan a QR or paste a code) with no accounts, CAs, or cloud services.
- Move archive trees β messages and media β verifiably and resumably over the LAN, then let each node's own ingest do the rest.
- Keep the loopback web UI posture byte-for-byte unchanged; make the new listener opt-in, mTLS-only, and doctor-observable.
Non-Goalsβ
- Internet/WAN sync β LAN only in v1; no relay, no hole punching.
- Multiple importers for the same source β structurally excluded.
- Selective or partial chat sync β v1 replicates whole archive roots.
- Mobile clients β desktop/server nodes only.
Decisionsβ
Archive sync over DB replicationβ
Choice: Replicate the archive file trees with hash manifests; every node derives its own database locally. Rationale: The archives already are the sync-shaped artifact β append-only, single-writer, idempotently ingestable. File-level sync moves media for free, never learns the schema, and makes DB migrations a node-local concern. Alternatives considered:
| Alternative | Why rejected |
|---|---|
| Litestream / LiteFS | Single-writer WAL streaming to read replicas β wrong topology for peer devices, and media files never travel. |
| cr-sqlite (CRDTs) | Merges multi-writer databases; heavy dependency solving conflicts our single-importer-per-source topology structurally does not have. |
| Cloud relay / hosted E2EE service | Violates the local-only posture outright (π ADR-0010). |
| External Syncthing | Works today, manually; but no pairing UX, no msgbrowse-aware verification, no doctor coverage. Documented as a DIY path, not the product. |
Mutual TLS from pairing-pinned self-signed certificatesβ
Choice: Each node generates a long-lived self-signed cert on enablement;
fingerprints exchange at pairing (QR carries the importer's, the pairing
request carries the replica's); all subsequent traffic is TLS 1.3 mutual auth
with exact fingerprint pinning.
Rationale: The QR/manual code is an out-of-band secure channel carrying a
full-entropy fingerprint, so we do not have the low-entropy-secret problem
PAKEs exist to solve. TLS is in the Go standard library (pure-Go, keeps
CGO_ENABLED=0 sacred), gives durable peer identity for revocation, and gets
streaming, session management, and anti-replay for free.
Alternatives considered:
- PAKE (SPAKE2 / Magic-Wormhole style): excellent when the shared secret is a short human word-code, but it yields an ephemeral session β we would still need to mint and pin long-term identities afterward, which is exactly the cert step. Adds a dependency to solve a problem the QR already solved.
- Noise protocol framework: fine cryptography, but a vendored framework plus
hand-rolled identity persistence, framing, and resumption β all things
crypto/tlsprovides, audited, in the standard library.
Dual discovery: QR-embedded endpoint + mDNS/DNS-SDβ
Choice: The QR/manual payload carries the importer's literal
host:port, which is used for pairing and stored as the peer's address;
sync-enabled nodes additionally advertise/browse a DNS-SD service type via
mDNS to re-find peers whose LAN address changed.
Rationale: mDNS does not cross VLANs or container network namespaces
reliably, so it can never be the only path β the literal endpoint always
works on the network where pairing happened. mDNS earns its keep afterward,
healing DHCP churn without user action.
Alternatives considered: mDNS-only (fails exactly when the user has
segmented their network β likely for this audience); static addresses only
(breaks silently on DHCP renewal; doctor can flag it, mDNS can fix it).
Pull-based transfer with staging and atomic adoptionβ
Choice: Replicas pull: fetch manifest, diff against local sync state,
GET files with Range resumption into a staging directory on the archive
root's filesystem, verify SHA-256, fsync, atomically rename into place;
ingest triggers only after the round's adoptions complete.
Rationale: Pull keeps all transfer state and backpressure on the node
that needs the data; resume is a byte offset, not a protocol negotiation.
Atomic rename means an archive path never holds a torn file, so SPEC-0001
ingest is safe at any instant. The importer's /v1/notify is a latency
optimization, deliberately advisory β losing every notification degrades to
the polling interval, never to missed data.
Alternatives considered: importer-push uploads (moves retry/resume state
onto the wrong node, and creates the unbounded-upload surface the security
section would then have to defend); rsync delegation (external binary,
no mTLS pinning integration, opaque verification).
Single importer per sourceβ
Choice: Roles are recorded per source at pairing. Replicas refuse manifest entries for a source from any peer other than its registered importer; a second importer claim for a claimed source fails closed with an error naming the incumbent. Rationale: Matches reality (one Mac runs the exporters) and buys the no-conflicts property the whole design leans on. Failing closed turns a misconfiguration into a doctor-explainable error instead of interleaved archive writes. Alternatives considered: last-writer-wins adoption (silently corrupting β two exporters' outputs interleaved in one root); no enforcement (same, later and harder to debug).
Node-local sync-state tablesβ
Choice: A small set of sync-state tables in the existing SQLite DB: peer
registry (device name, role-per-source, pinned fingerprint, last-known
address), manifest cache with generation numbers, and transfer cursors.
Never synchronized β it is operational state about this node's view.
Rationale: Manifest diffs and bootstrap resume need durable cursors;
the DB is already there, migrated, and transactional. Keeping sync state out
of manifests preserves "DB is derived, archives are truth."
Alternatives considered: flat files in data_dir (reinvents transactions
for the cursor+generation atomicity the spec requires); embedding state in
the archive tree (violates read-only archives).
As built (schema v9, #104): two tables. paired_devices is the peer
registry β one row per peer keyed by the UNIQUE pinned fingerprint, with a
JSON roles column mapping source β role (the registry holds a handful of
rows, is read whole, and single-importer-per-source enforcement happens in
one transaction where the error can name the incumbent). sync_state holds
both manifest generations (the rel_path = '' row per peer+source) and
per-file transfer cursors (rel_path <> '' rows) so "round adoption is
atomic in sync state" is a single-transaction write to one table. Deleting a
paired_devices row IS revocation; cursor rows cascade with their peer.
Naming: the devices namespaceβ
Choice (resolving the naming Open Question, #104): the sync verb stays
with π ADR-0015's exportβimport pipeline (msgbrowse sync); device sync uses
the devices namespace everywhere. The canonical spellings every later
story in the epic uses:
| Surface | Spelling |
|---|---|
| Go package | internal/devices |
| Config block | device_sync: (enabled, listen_addr, device_name, poll_interval, staging_dir) |
| CLI | msgbrowse devices pair|list|unpair|status |
| Web routes | /settings/devices/... (pairing open/close, unpair) |
| Schema | paired_devices, sync_state |
| DNS-SD service type | _msgbrowse-devices._tcp (provisional until the mDNS story) |
Rationale: devices names the noun the user manages (pair/list/unpair a
device), leaving sync unambiguous. The config block keeps the
device_sync spelling because devices: alone reads as a list of devices
rather than a feature toggle; it is the one deliberately different surface.
The π ADR-0015 collision is thereby resolved.
Pairing payload wire format (v1)β
Owned by this spec, rendered by SPEC-0010's Connect page, defined in code at
internal/devices/payload.go (PairingPayload). Version 1 is compact JSON
with exactly four fields:
{
"v": 1,
"endpoint": "192.168.1.10:8788",
"token": "Kf3β¦43-char-base64urlβ¦",
"fp": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
vβ integer protocol version; decoders reject anything but1(the payload is a one-shot secret between two builds the same operator controls, so there is no compatibility window).endpointβ the importer's sync listener as literalhost:port.tokenβ the single-use pairing token: base64url (RawURLEncoding, 43 chars) of 32crypto/randbytes.fpβ SHA-256 of the importer's TLS certificate DER: exactly 64 lowercase hex characters, no colons (the canonical fingerprint form used everywhere: payload, registry, logs).
Two presentations carry identical fields: the QR bytes are the compact
JSON itself; the manual code is MSGB1. + base64url(compact JSON, no
padding) β one paste-safe token with no whitespace. The payload contains a
live secret: loopback-only display, never logged, dead within β€ 10 minutes.
Architectureβ
Two flows carry the whole design: the one-time pairing handshake, and the steady-state sync round.
Pairing handshakeβ
Steady-state sync roundβ
Risks / Trade-offsβ
- mDNS flakiness across VLANs and container networks β the QR carries the
literal endpoint, addresses are persisted at pairing and manually editable,
and
doctordistinguishes "peer unreachable at last-known address" from "not discoverable via mDNS." - Large first sync (years of media can be tens of GB) β Range-resumable transfers with persisted per-file cursors; bootstrap survives restarts and reports real progress. Bandwidth caps are an open question, not a v1 blocker.
- Clock skew vs token TTL β TTL is enforced solely against the issuing node's clock (it timestamps issuance and judges expiry), so peer skew cannot extend or shorten the window; only the displayed countdown is approximate.
- Archive mutation during transfer (an exporter pass rewrites
chat.mdmid-fetch) β manifests are computed only after an ingest pass completes, and every fetch is hash-verified against the manifest; a torn read fails verification, is discarded from staging, and re-queues for the next round. The archive root itself is never touched until rename time. - Replica disk usage β full archive duplication per node is the explicit cost of media-complete sync; documented, surfaced in status, and the reason selective sync stays on the roadmap rather than in v1.
- Certificate lifetime β long-lived self-signed certs avoid renewal
machinery in v1 but make expiry a slow-burning trap;
doctorwarns well ahead of expiry, and the rotation story is an open question. - New crypto/identity surface β confined to stdlib TLS with pinning (no WebPKI, no CA logic); the pairing state machine is small, stateful, and race-tested per the spec's concurrency requirement.
Migration Planβ
- Config: a new
device_syncblock (enabled,listen_addr,device_name,poll_interval,staging_dirβ spellings final per the naming decision above; landed in #104). Absent block β feature fully off; no existing key changes meaning. - Schema: one migration (v9, landed in #104) adding the node-local
sync-state tables (
paired_devices,sync_state). No existing table changes; the tables are inert on nodes that never enable device sync. - Surfaces: settings gains the devices section; CLI gains pair/status/
unpair commands;
doctorgains the listener-posture, peer-reachability, cert-validity, staleness, and staging-leftover checks. - Docs site: a multi-device page covering pairing, the importer/replica model, disk expectations, and the manual-Syncthing alternative.
- Rollback: disable the config flag β the listener stops, pairing windows invalidate, and the node reverts to the pure loopback posture. Already-synced archives remain browsable read-only; the sync-state tables sit inert.
Open Questionsβ
- Windows timing: importers are macOS by necessity; Linux/macOS replicas come first. When (and whether) a Windows replica is worth the path and fsync semantics work.
- Bandwidth caps / transfer windows: should big media syncs be throttled or schedulable (e.g. overnight), and does that change the v1 "no rate limits on mTLS endpoints" stance?
- Replicas as importers for different sources: roles are per source, so a second machine running the exporter for a source the Mac cannot produce looks structurally sound (multi-importer-per-distinct-source). Deliberately unvalidated in v1 β the enforcement only forbids two importers for the same source.
Naming: theβ resolved in #104: thesyncword is takendevicesnamespace on every surface (see "Naming: thedevicesnamespace" above).- mDNS library: which pure-Go mDNS/DNS-SD implementation (must keep
CGO_ENABLED=0), and the service-type string to register. - Cert rotation: renewal/re-pairing UX before long-lived certs expire; whether re-pairing is acceptable as the v1 rotation mechanism.
.snapshotshandling: the spec defaults the encrypted SQLCipher backups to excluded from manifests; confirm whether an opt-in to sync them (as opaque bytes) is wanted for off-Mac backup redundancy.- Default poll interval: pick a fallback interval that balances convergence latency against waking disks on idle home servers.
Related Artifactsβ
Direct relationships declared in YAML frontmatter (per the SDD plugin's ADR-0023 / SPEC-0018 frontmatter-graph conventions). Run /sdd:graph chain SPEC-0011 for the transitive view.