TinySync Understand Integrate Decide FAQ Playground ↗
Decide · Decision record

Key v1 Decisions

ADOPTED2026-05 · v1 design phase

The locked-in architectural calls that shaped v1, and why each one went the way it did.

Context

v1 had to ship a trustworthy sync core on two operating systems with a small team. Every decision below trades scope for shippability and debuggability. The goal was one thing done bulletproof, not ten things done okay. These calls are locked; reopening any of them requires genuinely new information, not just a new mood.

The decisions

Language: Rust, not Go

Chosen: Rust
Rejected: Go
Three things tipped it, each independently sufficient:

(1) Swift FFI. macOS File Provider extensions are Swift binaries. Rust compiles to cdylib and links directly. Go's runtime forces a separate process and an extra IPC layer at the exact boundary where we need the most reliability.

(2) Windows CFAPI bindings. windows-rs is Microsoft-official and idiomatic. Go's COM story is manual and poorly maintained.

(3) State machine correctness under concurrency. The sync engine is a state machine where concurrent writes must be caught at compile time. Rust's Send/Sync and borrow checker do this. This matters more when code is AI-assisted and may not be deeply reviewed.

Topology: Cloud-authoritative, not P2P

Chosen: Cloud as single source of truth
Rejected: P2P / mesh sync
Mesh sync requires CRDTs or vector clocks — neither fits file semantics well. CRDTs work for counters and text; they produce awkward semantics for "which binary blob is the current version of a file." Vector clocks require NAT traversal infrastructure and produce "where is the right version?" ambiguity that makes debugging nightmarish.

Cloud-authoritative gives one source of truth, deterministic conflict ordering (server seq), and simpler ops. LAN peer-assist can be added later as an optimization on top of a correct cloud-ordered log — not as a topology change.

Build order: Engine-first, OS adapters later

Chosen: Prove the engine first with a folder-watcher prototype; OS adapters before beta
Rejected: Build OS adapter and sync engine concurrently
OS adapters are production-grade integration work with deep edge cases: placeholder state corruption, antivirus interference, hydration deadlines, concurrent callback invocations. Building them concurrently with the engine means shipping a happy-path demo with subtle correctness bugs that surface in month two.

Engine-first with the PresentationAdapter interface means ~90% of client code is reusable when adapters arrive. The ~10% throwaway is the folder-watcher loop — the price of de-risking the engine first. The folder-watcher adapter still exercises the real engine, server protocol, SQLite state, conflicts, tombstones, and log replay.

Process architecture: Thin extension + daemon, not engine-in-extension

Chosen: Thin File Provider extension that IPC's to a per-user daemon
Rejected: Link the full Rust engine directly into the File Provider extension
Swift-to-Rust FFI is mechanically viable (proven in batch 14), but the File Provider extension lifecycle is the wrong place to own the sync engine. The OS can spawn multiple extension instances, invalidate them, cancel callback progress, and issue concurrent hydration requests simultaneously.

SQLite ownership, server credentials, WebSocket poll loops, retry policy, panic-resync state, and event emission need one durable local owner — shared by the CLI, Tauri app, and native provider. That owner is the per-user daemon (tinysync-daemon). The extension is a thin callback translator; the daemon is the engine.

Desktop app role: Tauri as control plane only

Chosen: Tauri owns setup, status, device management — nothing else
Rejected: Tauri owns sync semantics
The product depends on OS-native provider semantics: macOS File Provider and Windows CFAPI own enumeration, placeholder state, hydration callbacks, and shell integration. A Tauri app cannot own these — they belong to the OS extension lifecycle, not the app process lifecycle.

If Tauri owned sync, quitting the app would stop sync. Files would only appear in a Tauri-managed window, not in Finder or Explorer. The native cloud-drive experience would be impossible. Tauri is the control plane. The daemon is the engine. The OS provider is the filesystem face.

IPC protocol: Bounded JSON over local sockets, not gRPC

Chosen: 4-byte length-prefixed JSON over Unix domain sockets (macOS) / named pipes (Windows), max 1 MiB/frame
Rejected: gRPC over UDS, bespoke binary protocol
The daemon-provider IPC contract is still evolving as native providers are built. gRPC adds protobuf schema machinery and toolchain dependencies before the boundary is proven. A bespoke binary protocol hides the thing we most need to inspect when debugging callback translation.

JSON is not used for file bytes — hydration and local edits move by daemon-approved file references, so IPC stays bounded and readable. The cap of 1 MiB/frame prevents accidental large-payload abuse while keeping the wire format debuggable with jq. The protocol is capability-negotiated at connect time so components can be updated independently without silent schema drift.

Write path: Native-provider writes bypass PresentationAdapter

Chosen: Daemon submits provider-pushed mutations directly through shared mutation helpers
Rejected: Route all writes through PresentationAdapter
PresentationAdapter models the eager/watcher case: the engine observes local filesystem state through an adapter, derives changes, and queues mutations. Native providers invert that flow. When a user saves a file in Finder, the OS extension is called with a concrete user operation — the daemon doesn't need to discover it by watching the filesystem.

Forcing this through the adapter would add an artificial "watch and rediscover" layer for a change the OS already reported. The reuse boundary is enforced differently: daemon writes must share blob upload, mutation submission, idempotency, conflict detection, and accepted-event application helpers with the CLI/eager path. They must not invent provider-only mutation semantics.

Cloud-drive location, not arbitrary folders

Chosen: Dedicated cloud-drive location
Rejected: Dropbox-style "attach to any folder"
Since macOS 12.3, File Provider extensions are mandatorily mounted under ~/Library/CloudStorage/<App>/. Apple removed the choice; Dropbox fought this and lost. Matching this layout on Windows keeps the product consistent across platforms and removes a class of user-configuration errors entirely.

Conflict policy: Optimistic concurrency with conflict-copy preservation

Chosen: Optimistic concurrency with conflict-copy preservation
Rejected: True last-writer-wins, three-way merge, CRDT, manual conflict UI
Existing-item mutations carry a base_item_version. The first writer whose base version still matches the server is accepted as the canonical item. Later stale writers are rejected and their local content is preserved as conflict copies. This is not true last-writer-wins: a confirmed server write is not silently overwritten by a later stale write.

Anything smarter — app-specific merge, manual conflict UI — can replace the conflict policy later without changing the storage model. The key property is that correctness is observable and auditable from the server log.

Auth: Capability tokens, not user accounts

Chosen: Capability tokens per vault
Rejected: Per-user accounts, device-pairing
N-device sync falls out of the change-log model naturally — each device subscribes from its last-seen sequence number. No user model is required inside TinySync. A join token exchanges once for a revocable device credential, so device-level revocation works without rebuilding the auth model later.

The token model can attach to a parent user system that federates tokens. Keeping "user semantics" outside TinySync keeps the engine clean and agnostic to future shapes such as service accounts and agent access.

Superseded by Device Identity & Cardinality (2026-06): join tokens were removed; access is group membership.

Item identity: Item IDs, not paths

Chosen: Stable item IDs; paths derived
Rejected: Path-as-identity
Path-as-key breaks at the first folder rename. Decoupling identity from path makes rename, move, and case-only changes first-class operations throughout the system. The protocol, conflict resolution, and adapter contracts all become cleaner. Server seq is the ordering truth for all operations; wall-clock timestamps are display-only.

Unified ClientEvent stream

Chosen: One structured ClientEvent stream shared by all local surfaces
Rejected: Each surface (CLI, Tauri, provider) shapes its own status feed
Sync status, progress, wakeups, diagnostics, history attribution, terminal errors, and panic-resync requirements should mean the same thing everywhere. A unified stream gives the CLI human/JSON output, the Tauri control plane, and native-provider diagnostics one contract to consume. It also makes support logs and remote test evidence comparable across surfaces.

Consequences

Taken together, these decisions make correctness, auditability, and a simple server straightforward to achieve: every mutation is cloud-ordered by seq, every conflict is preserved not silently overwritten, every device is independently revocable, and the engine's state is fully reproducible from the server log. The tradeoffs deferred to later are bandwidth efficiency (whole-file uploads instead of deltas), end-to-end encryption (server-side dedup and conflict diagnostics require plaintext access today), and offline-first collaboration (cloud-authoritative topology is the right foundation but requires additional work for concurrent multi-user edits on disconnected devices).