SPEC-0014 Design: Syncthing-based device sync
Context
SPEC-0014 implements 📝 ADR-0021: multi-device msgbrowse via a bundled, supervised Syncthing as the transfer engine, replacing the bespoke engine SPEC-0011 specified. The pivot is a build-versus-buy reversal, not a change of shape — the same archive-sync model, a different transport underneath.
What carries over unchanged from 📝 ADR-0018 / SPEC-0011:
- Archive-sync, not DB-replication. Sync moves the archive files; each node runs its own SPEC-0001 ingest into its own SQLite. The database is never transferred. This is why file-level sync was always the right model — Syncthing just does the file-level part better than we would.
- Importer/replica roles. Only a node that can run the exporters (Signal Desktop key, Full Disk Access, phone backups — 📝 ADR-0015/📝 ADR-0016) imports from live sources; replicas receive archives and ingest them. One importer per source. The topology is still single-writer-per-source, so there are still no conflicting writes to reconcile.
- The re-ingest pipeline.
internal/onboardsvc's incremental import (the Enable/Refresh runner) is the re-ingest primitive on every node, untouched by this pivot.
What Syncthing replaces (all the security-critical, bug-prone transport):
- msgbrowse-issued self-signed certificates pinned at pairing → Syncthing's device ID (SHA-256 of the device's TLS cert) and mutual TLS.
- Single-use TTL pairing tokens → a public device ID plus explicit both-ends acceptance.
- Per-source hash manifests, resumable byte-range transfer, staging with atomic adoption, bootstrap resume → Syncthing's block-level sync engine.
- mDNS/DNS-SD discovery, notify/poll convergence → Syncthing's local discovery and its always-on sync loop.
Expected touchpoints: a new internal/syncthing supervisor + REST/events client
package; config generation that maps managed archive roots to Syncthing folders;
the folder-watch → re-ingest worker; settings-page changes swapping the pairing
payload from a token to a device ID; a schema repurpose of paired_devices /
sync_state; the removal of internal/devices token/identity crypto and
internal/devices/listener; and doctor checks that read Syncthing's REST API.
The pure-Go CGO_ENABLED=0 core (📝 ADR-0013) is unaffected — Syncthing is a
separate process, not a linked dependency.
Goals / Non-Goals
Goals
- Pair a replica with an importer in one physical action (scan a QR of a device ID, or paste it) with no accounts, no CAs, no cloud, and no msgbrowse-issued certificates.
- Move archive trees — messages and media — reliably and resumably via Syncthing, then let each node's own ingest do the rest.
- Keep the loopback web UI posture unchanged; keep Syncthing's REST/GUI API on loopback with an API key; make device sync opt-in and doctor-observable, with the user never seeing Syncthing's own UI.
Non-Goals
- Global relay/discovery by default — LAN + local discovery only in v1; cross-network relaying is an owner-gated opt-in, documented as internet egress.
- Hand-editing Syncthing config — msgbrowse owns config generation entirely.
- Exposing Syncthing's GUI — its state is surfaced through msgbrowse's own Settings/Status/doctor.
- Database replication — structurally excluded; the DB is derived, per-node.
- Windows/Linux bundling in v1 — the transfer engine is cross-platform, but
the
.appbundle is macOS-first, owner-gated like the exporter bundle.
Decisions
Bundle-and-supervise over library-embed
Choice: Bundle the Syncthing binary under Contents/Resources and drive it
as a supervised child through its REST/events API, rather than embedding
Syncthing as a Go library linked into msgbrowse.
Rationale: Syncthing's internals are not a stable, supported embeddable API —
its module boundaries change between releases and it is designed to run as a
daemon, not as a library. Bundling the binary and driving the REST API gives a
stable contract (the documented REST/events API), an independent security-update
path (swap the binary, re-notarize), and process isolation. It also mirrors the
exporter-bundling pattern already established in
📝 ADR-0020.
Alternatives considered: library-embed (rejected in v1 — no stable
embeddable API, couples msgbrowse's build and lifecycle to Syncthing internals;
kept as an Open Question for later); depend on user-installed Syncthing (rejected
by 📝 ADR-0021 — breaks the
zero-config consumer app).
msgbrowse owns config generation
Choice: msgbrowse generates Syncthing's entire config via the REST API —
folders are the managed archive roots under <data_dir>/archives/<source> with
ignore patterns excluding the DB and all data_dir state; devices are paired
peers — and the user never edits config or opens the GUI.
Rationale: Config generation is where the invariants are enforced (no DB in a
synced folder; exactly the managed roots and nothing else) and where the
zero-config promise lives. Owning it end-to-end means the user manages sources
and peers, never Syncthing folders and device blocks.
Alternatives considered: let the user configure Syncthing (rejected —
reintroduces the friction the desktop product removes, and lets a misconfigured
folder pull the DB into sync); a static shipped config (rejected — folders and
peers are inherently dynamic).
Device-ID QR pairing over TTL tokens
Choice: The pairing QR/manual code carries this node's Syncthing device ID plus a folder introduction — a public identifier, not a secret — and pairing completes by adding the peer device and sharing folders via the REST API, gated by Syncthing's both-ends device acceptance. Rationale: Syncthing's device ID is the pinned-cert identity SPEC-0011 was going to mint and pin; reusing it drops the entire token/TTL/rate-limit state machine and the constant-time-compare replay defenses, because there is no secret to leak or replay. Acceptance on both ends — not knowledge of the ID — gates sync, so a captured QR is inert. Alternatives considered: keep SPEC-0011's single-use TTL tokens (rejected — solves a low-entropy-secret problem we no longer have, and adds a stateful pairing window Syncthing already renders unnecessary); an msgbrowse-layer secret on top of the device ID (rejected — redundant with Syncthing's acceptance step).
Folder-watch trigger: REST events with an fsnotify fallback
Choice: Trigger re-ingest from Syncthing's REST/events API
folder-completion signals (FolderCompletion / FolderSummary events), with
fsnotify on the synced folder as a fallback, and gate re-ingest on the folder
being complete (not mid-transfer).
Rationale: The events API is the authoritative "this folder just reached
100%" signal and distinguishes completion from an in-flight transfer, which
fsnotify alone cannot — a naive filesystem watch would fire mid-sync and import
a partial archive. fsnotify is the belt-and-suspenders fallback if the event
stream drops. Gating on completion is what keeps the re-ingest from running
against a torn tree.
Alternatives considered: fsnotify-only (rejected — cannot tell completion
from mid-transfer, risks importing partial archives); polling the REST folder
summary on an interval (kept as a coarse fallback, not the primary trigger —
higher latency, wakes idle disks).
Relay/discovery defaults: LAN-only
Choice: Generate Syncthing config with global discovery and relaying OFF, local (LAN) discovery ON; global discovery/relay is an explicit owner opt-in. Rationale: Global discovery and relays reach public internet servers, which touches egress against 📝 ADR-0010. LAN-only keeps the default posture "nothing leaves the LAN," matching the two-machines-in-one-house use case; owners who genuinely need cross-network sync opt in knowingly. Alternatives considered: relay-on by default (rejected — silent internet egress inverts the privacy default); relay never (rejected — forecloses a legitimate owner-gated use case).
Architecture
Two views carry the design: the container topology (what bundles, supervises, configures, and syncs), and the pairing sequence (device-ID QR → shared folder → first import).
Container diagram
Pairing sequence
Risks / Trade-offs
- Bundle size + Syncthing signing/notarization → Syncthing is another
embedded executable under
Contents/Resources, adding to app size and to the Gatekeeper signing/notarization surface established by 📝 ADR-0020; every release signs and notarizes it alongside the exporters. - Supervising a third-party daemon → msgbrowse owns Syncthing's lifecycle
(clean start, graceful stop on app quit with no orphan, restart-with-backoff on
crash) and must translate its REST/events model into msgbrowse status and
doctor; a mapping layer to build and keep current across Syncthing versions. - Version pinning + security-update cadence → we track Syncthing releases for security fixes and re-bundle + re-notarize to ship them, exactly as for the bundled exporters; the pinned hash makes the shipped version knowable and the update deliberate.
- Retirement of #104/#105 code → the merged pairing-token/identity crypto and the mTLS LAN listener are removed (sunk cost); only the QR/pairing UX shape and the schema tables carry forward, and the removal must leave no dangling pinned certificates or token windows.
- Long-lived device IDs vs TTL tokens → a device ID is durable, not time-boxed. This is safe because acceptance (not knowledge of the ID) gates sync, but it is a genuine trust-model change from SPEC-0011's ≤10-minute window and must be documented for operators.
- Re-ingest against a partial tree → mitigated by gating re-ingest on Syncthing's folder-completion event rather than a raw filesystem change, with overlapping events serialized per source so two imports never race one root.
Migration Plan
- Retire the mTLS listener + token crypto. Remove
internal/devices/listenerand theinternal/devicespairing-token windows, self-signed identity, and versioned token payload (#104/#105). No msgbrowse-issued certificate is generated or pinned for device sync thereafter. - Adapt the schema. Repurpose
paired_devices(peer's Syncthing device ID, device name, role-per-source, folder mapping) andsync_state(folder ↔ source mapping, last-completed generation/summary as reported by Syncthing) rather than pinned fingerprints and byte-range cursors. A migration converts or clears any existing SPEC-0011 rows so no dangling pinned certs remain. - New
internal/syncthingsupervisor package. Bundle resolution + integrity check, child-process lifecycle (start/stop/backoff), config generation (folders from managed archive roots; devices from peers), a REST/events client (loopback + API key), and the folder-completion → re-ingest trigger drivinginternal/onboardsvc. - Swap the pairing surface.
/settings/devicesrenders a device-ID QR + manual code instead of a token payload; pair/unpair drive the Syncthing REST API (add/remove device, share/unshare folder). - Status + doctor. Surface Syncthing's peer/folder state from its REST API
into Settings/Logs/Status and add
doctorchecks (daemon running when enabled, peer connection state, folder completion/staleness, folder errors). - Rollback. Disable the config flag — the Syncthing child stops, no P2P listener remains, and the node reverts to the pure loopback posture. Already-synced archives stay browsable read-only; the sync-state tables sit inert.
Open Questions
- Library-embed feasibility later: whether a future Syncthing offers a stable embeddable API worth linking in-process to shed the child-process supervision, and at what version.
- Relay opt-in UX: how to present the owner-gated global-discovery/relay toggle so its internet-egress implication is unmistakable, and whether it is ever appropriate for the default consumer path.
- Headless/replica QR display: how a node with no interactive UI (a home server) surfaces or consumes the device-ID pairing code — manual device-ID entry, a one-shot local page, or a CLI print.
- Windows/Linux Syncthing bundling: when the transfer engine (already
cross-platform) gets an owner-gated bundle on those platforms, following the
.appprecedent. - Retaining #104 pairing primitives: whether any of the retired
internal/devicespairing UX helpers (QR rendering, manual-code encoding) are worth keeping in adapted form versus removing wholesale. - Ignore-pattern hardening: the exact Syncthing ignore patterns that
guarantee no
data_dir/DB/WAL state can ever enter a synced folder, including when an archive root anddata_dirshare a parent.
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-0014 for the transitive view.