← TinySync wiki Appendix · single-page reference Understand Integrate Decide
System Design · v1

TinySync Architecture
Reference

A cloud-authoritative file sync engine with OS-native desktop integration. Single reference for system design, data model, sync protocol, and core invariants.

Rust · Axum · SQLite · PostgreSQL macOS File Provider Windows CFAPI Content-addressed blobs Optimistic concurrency

01 / Product Shape

TinySync is a cloud-authoritative file sync engine. Think Dropbox — not a database sync library, not a collaborative editing framework, not a peer-to-peer tool.

The product contract: put a file in a cloud drive location on any device, and it appears — on demand — on every other device. The OS presents the drive as if all files exist locally, even when content hasn't been downloaded yet (placeholders). Files hydrate when opened.

What TinySync syncs
Files and folders — binary blobs with a hierarchical directory structure. Renames and moves are first-class operations, not delete + recreate.
What TinySync does not sync
Structured relational rows, app-level databases, or schema-driven tabular data. There is no SQL query interface on the client.

Topology

The cloud server is the single source of truth. No peer-to-peer, no CRDT merge, no multi-master replication. Every write from any device must be accepted by the server before becoming canonical. Other devices learn about it by replaying the server's ordered change log.

This eliminates "where is the real version?" ambiguity and makes conflict resolution deterministic: the first write whose precondition still holds wins; all others get conflict copies.

02 / System Context — C4 Level 1

At the highest level, TinySync sits between users on multiple devices and the infrastructure that stores their files.

C4 Level 1 — System Context
graph TB subgraph Users["Users (n devices)"] U1["💻 macOS Desktop"] U2["🖥️ Windows Desktop"] end subgraph TinySync["TinySync System"] Server["🔄 TinySync Server\n(Axum HTTP + WebSocket)"] end subgraph Infra["Infrastructure"] PG["🗃️ PostgreSQL\nVault metadata & change log"] Blob["☁️ Blob Store\nContent-addressed files"] end subgraph OSLayer["OS Integration Layer"] FP["🍎 macOS File Provider"] CF["🪟 Windows CFAPI"] end U1 --> FP U2 --> CF FP -- "HTTPS: mutations, log replay, blobs" --> Server CF -- "HTTPS: mutations, log replay, blobs" --> Server Server --> PG Server --> Blob
SystemRole
TinySync ServerAccepts mutations, maintains the authoritative change log, serves snapshots and log pages, hosts blob upload/download, pushes WebSocket wake notifications.
PostgreSQLAuthoritative store for vault metadata, item tree, change log (ordered by seq), device identities, groups and membership edges, idempotency keys, public link records.
Blob StoreContent-addressed binary store keyed by SHA-256(content). Identical files share one blob. Local filesystem in dev; object storage (R2/S3/B2) in production.

03 / Container Architecture — C4 Level 2

Each device runs several co-operating processes. The engine is separated from the OS integration layer, and a background daemon owns all mutable engine state.

C4 Level 2 — Containers (per device)
graph TB subgraph Device["Device (macOS or Windows)"] direction TB subgraph UX["Control Plane"] Tauri["🖥️ Tauri Desktop App\n(tray, status, vault management)"] CLI["⌨️ CLI\n(recovery, headless ops)"] end Daemon["⚙️ TinySync Daemon\n(per-user background service)\nOwns: SQLite · credentials · event stream"] subgraph Providers["OS Native Providers"] FPExt["🍎 File Provider Extension\n(macOS)"] CFProc["🪟 CFAPI Provider Process\n(Windows)"] end end subgraph Cloud["Cloud"] Server["🔄 TinySync Server"] PG["PostgreSQL"] Blobs["Blob Store"] Server --- PG Server --- Blobs end Tauri & CLI -- "JSON / IPC" --> Daemon FPExt -- "JSON over Unix socket" --> Daemon CFProc -- "JSON over named pipe" --> Daemon Daemon -- "HTTPS + WebSocket" --> Server
ContainerOwnsDoes not own
TinySync DaemonSQLite engine state, credentials, server polling, WebSocket, pending ops queue, retry, panic-resync, unified event streamOS placeholder state, shell callbacks, Finder/Explorer presentation
File Provider Ext (macOS)Placeholder registration, Finder enumeration, hydration callbacks, own-write suppressionCredentials, SQLite, server protocol, conflict policy
CFAPI Process (Windows)Sync root registration, placeholder operations, Explorer callbacks, byte-range hydrationSame as above
Tauri AppSetup UI, tray, status display, device management, process supervisionSync semantics — it is a control plane only
CLIRegister, attach, admin, sync-once, status, devices, panic-resyncOwns no state — reads from daemon or engine directly
Why a separate daemon?
The macOS File Provider extension has an OS-controlled lifecycle. The Tauri app can be quit by the user. Neither is the right owner of SQLite, credentials, or WebSocket connections. A per-user background daemon (macOS LaunchAgent, Windows user-mode service) gives sync one durable, crash-recoverable owner. Quitting the UI does not stop sync.

04 / Sync Engine Components — C4 Level 3

The daemon contains tinysync-engine — the heart of the system. It is isolated behind two traits: one facing the cloud, one facing the OS.

C4 Level 3 — Engine Component View
graph TB subgraph Engine["tinysync-engine"] SE["SyncEngine<A>\nOrchestrates all sync logic"] LS["LocalStore (SQLite)\nItem state · pending ops · engine_state kv"] CC["CloudClient trait\n(HTTP abstraction)"] PA["PresentationAdapter trait\n(OS abstraction)"] SE --> LS SE --> CC SE --> PA end subgraph Adapters["Adapter Implementations"] FSA["FolderWatcherAdapter\n(prototype, eager)"] FPA["FileProviderAdapter\n(macOS, lazy)"] CFA["CFAPIAdapter\n(Windows, lazy)"] MockA["MockAdapter (tests)"] end subgraph CloudImpls["CloudClient Implementations"] HC["HostedCloudClient (HTTPS)"] TestC["TestCloudClient (tests)"] end PA -.-> FSA & FPA & CFA & MockA CC -.-> HC & TestC

The Two Trait Boundaries

CloudClient — server-facing

trait CloudClient {
  get_snapshot(vault_id) → Snapshot
  get_log(vault_id, after: Seq)
    → LogPage
  upload_blob(vault_id, hash, bytes)
  download_blob(vault_id, hash)→Bytes
  submit_mutation(vault_id, req)
    → MutationResponse
}

The engine has no HTTP dependency. Swapping in a test double is trivial — and that's how all engine tests run.

PresentationAdapter — OS-facing

trait PresentationAdapter {
  hydration_strategy()→Eager|Lazy
  apply_create(item, content)
  apply_update(item, content)
  apply_delete(item)
  apply_move_rename(from, to)
  enumerate_local()→Vec<LocalEntry>
  read_local_content(path, hash)→Bytes
  preserve_conflict_copy(path,
    op_id, device_id)→Option<VPath>
}

The engine knows nothing about notify events, File Provider callbacks, or CFAPI requests. It speaks in sync intent.

05 / Crate Map

CrateLayerContents
tinysync-coreSharedAll protocol DTOs, ID types, content hashing, path/name validation, Unicode normalization.
tinysync-serverServerAxum HTTP API, Postgres queries (sqlx), blob store trait + FS impl, WebSocket wake hub (in-process + Postgres LISTEN/NOTIFY), token auth.
tinysync-engineClientSyncEngine, LocalStore (SQLite), PresentationAdapter + CloudClient traits, all sync state machine logic, conflict detection, pending ops queue, panic-resync.
tinysync-adapter-fsClientPrototype eager adapter: filesystem watcher (notify), file materialization, content hashing, own-write suppression, inode-based rename detection.
tinysync-provider-macos-ffiClientThin Swift-Rust FFI shim for macOS File Provider callback bridge.
tinysync-provider-windowsClientWindows CFAPI sync root registration, placeholder management, hydration callback handler.
tinysync-provider-ipcSharedLength-prefixed JSON IPC protocol (4-byte big-endian + JSON body, max 1 MiB/frame) between native providers and daemon.
tinysync-daemonClientPer-user background daemon: IPC server, engine orchestration, multi-vault routing, client event stream, wake subscription.
tinysync-clientClientHTTP implementation of CloudClient, device identity storage, sync loop primitives.
tinysync-desktopUITauri shell, tray, daemon lifecycle management, settings UI.
tinysync-cliUICLI: register, attach, admin, sync-once, status, devices, panic-resync.

06 / Core Data Model — The Item Tree

Items

The fundamental entity is an item — a file or a folder. Items form a tree rooted at a synthetic root folder. All items have a parent item ID, except the vault root.

struct ItemView {
    item_id:        Uuid,          // stable — never changes across rename/move
    parent_item_id: Option<Uuid>, // None only for vault root
    name:           String,        // filename only, no path separators
    path:           VPath,         // derived from ancestry — not stored as primary key
    kind:           ItemKind,      // File | Folder
    version:        u64,           // incremented on each accepted mutation
    metadata:       ItemMetadata,
}

struct ItemMetadata {
    content_hash: Option<Hash>, // SHA-256 of content, files only
    size:         u64,
    mtime:        Option<DisplayTime>, // display only — NOT ordering truth
    deleted:      bool,
}
Identity vs Path
item_id is identity. path is a derived, cached string computed by walking parent IDs. If a folder is renamed, descendants' paths change but their item_ids remain stable. This is the only correct foundation for rename, move, conflict resolution, and OS placeholder mapping. Path-as-key breaks at the first folder rename.

Content Addressing

File content is stored separately from metadata. The server's blobs table is keyed by SHA-256(content). A file mutation first PUTs the blob (idempotent — the hash is the key), then submits a metadata mutation referencing that hash. Two files with identical bytes reference the same blob automatically.

Item Tree — Example
graph TD Root["📁 Root · item_id: root-uuid · parent: null"] Docs["📁 Documents · item_id: docs-uuid"] Photos["📁 Photos · item_id: photos-uuid"] Report["📄 report.pdf · hash: sha256(...)"] Notes["📄 notes.txt · hash: sha256(...)"] Vacation["📄 vacation.jpg · hash: sha256(...)"] Root --> Docs & Photos Docs --> Report & Notes Photos --> Vacation

Folder Subtree Semantics

  • Move/rename folder — one mutation, one change log event (MovedRenamed). Descendants' IDs don't change. Client rederives paths from the updated ancestry. A folder with 1,000 files is one operation, not 1,000.
  • Delete folder — one server transaction tombstones the folder and all descendants. The change log emits one DeleteSubtree event rather than one event per descendant.

Filename Rules (enforced at server boundary)

No path separators No Windows reserved chars No reserved device names (CON, NUL…) No trailing spaces or dots No empty names NFC Unicode normalization Case-insensitive sibling uniqueness Max path depth enforced

07 / Local Item State Machine

Every item in the local engine has a LocalKind value tracking its state relative to the server and local filesystem. Persisted in SQLite — survives crashes and restarts.

Absent
Known to the engine but no local filesystem presence. Starting state and tombstone state.
Folder
A directory exists locally. No content hash — identity and children matter.
Placeholder
File entry visible in Finder/Explorer but bytes not yet downloaded. Lazy adapters only.
Hydrated
File content fully present and matches the server's known hash. No pending upload or conflict.
Modified
Local content differs from last known server version. A pending upload is queued with an idempotent op_id.
Conflicted
Server rejected the upload (stale base) or remote update arrived while upload was pending. Local bytes preserved as conflict copy.
Per-Item LocalKind — State Transitions
stateDiagram-v2 [*] --> Absent : item first learned from server log Absent --> Folder : remote_create_folder / local_create_folder Absent --> Placeholder : remote_create_file (lazy adapter) Absent --> Hydrated : remote_create_file (eager) / local_create_file accepted Placeholder --> Hydrated : OS hydration callback fulfilled Placeholder --> Absent : remote_delete Folder --> Absent : remote_delete / local_delete accepted Hydrated --> Hydrated : remote_update clean / local overwrite accepted Hydrated --> Modified : local_modify detected Hydrated --> Absent : remote_delete / local_delete accepted Modified --> Hydrated : upload_accepted by server Modified --> Conflicted : stale_base_rejected / remote_update during pending upload Conflicted --> Hydrated : conflict_policy_applied

Move and rename operations do not change LocalKind. They update parent_item_id and name, and the engine rederives all affected path caches.

08 / Server-Side Schema

The server stores all authoritative state in PostgreSQL. The change log is the ordering backbone of the entire system.

-- Vault: root of a synced file tree
CREATE TABLE vaults (
    vault_id     UUID PRIMARY KEY,
    root_item_id UUID NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL
);

-- Item tree: every file and folder
CREATE TABLE items (
    item_id          UUID PRIMARY KEY,
    vault_id         UUID NOT NULL REFERENCES vaults,
    parent_item_id   UUID,
    name             TEXT NOT NULL,
    normalized_name  TEXT NOT NULL,   -- casefold for collision checks
    kind             TEXT NOT NULL,   -- File | Folder
    item_version     BIGINT NOT NULL, -- precondition for mutations
    content_hash     BYTEA,          -- SHA-256 of content, files only
    size             BIGINT,
    deleted_at       TIMESTAMPTZ,
    created_at       TIMESTAMPTZ NOT NULL,
    updated_at       TIMESTAMPTZ NOT NULL
);

-- Unique sibling names among live items
CREATE UNIQUE INDEX items_live_sibling_name
    ON items(vault_id, parent_item_id, normalized_name)
    WHERE deleted_at IS NULL;

-- Content-addressed blob store
CREATE TABLE blobs (
    content_hash BYTEA PRIMARY KEY,   -- SHA-256 is the key
    size         BIGINT NOT NULL,
    storage_key  TEXT NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL
);

-- Authoritative ordered change log
CREATE TABLE change_log (
    vault_id      UUID NOT NULL,
    seq           BIGINT NOT NULL,    -- monotonically increasing per vault
    op_id         UUID NOT NULL,
    device_id     UUID NOT NULL,
    item_id       UUID NOT NULL,
    event_kind    TEXT NOT NULL,      -- Created|Updated|Deleted|DeleteSubtree|MovedRenamed
    event_payload JSONB NOT NULL,
    committed_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (vault_id, seq)
);

-- Idempotency: replay-safe operation results
CREATE TABLE idempotency_keys (
    vault_id         UUID NOT NULL,
    device_id        UUID NOT NULL,
    op_id            UUID NOT NULL,
    request_hash     BYTEA NOT NULL,
    response_payload JSONB NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (vault_id, device_id, op_id)
);

-- Identity & access (migration 0004): a device is a physical
-- machine — one row, one credential, ever.
CREATE TABLE devices (
    device_id       UUID PRIMARY KEY,
    display_name    TEXT NOT NULL,
    credential_hash BYTEA NOT NULL CHECK (length(credential_hash) = 32),
    registered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_seen_at    TIMESTAMPTZ,
    revoked_at      TIMESTAMPTZ
);

-- Opaque to TinySync; meaning lives in the Platform Service.
CREATE TABLE groups (
    group_id     UUID PRIMARY KEY,
    display_name TEXT NOT NULL DEFAULT '',
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE group_devices (
    group_id  UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
    device_id UUID NOT NULL REFERENCES devices ON DELETE CASCADE,
    added_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (group_id, device_id)
);

CREATE TABLE group_vaults (
    group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
    vault_id UUID NOT NULL REFERENCES vaults ON DELETE CASCADE,
    added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (group_id, vault_id)
);
seq is the only ordering truth
seq is a monotonically increasing integer assigned by the server to every committed vault operation. Wall-clock timestamps are stored for display only. Clients order events by seq, not by clock time. Clocks drift, get spoofed, and produce happens-before ambiguity. seq does not.

Retention

DataRetention
Change log entries90 days
Tombstones (deleted_at)90 days
Idempotency keys7–30 days

The server exposes min_retained_seq alongside latest_seq. A client reconnecting after last_seq_processed < min_retained_seq must run a full snapshot resync rather than incremental log replay.

09 / Sync Protocol — Bootstrap

When a device first joins a vault, or when local state is too stale for incremental replay, it runs bootstrap: snapshot + log replay.

Bootstrap Sequence
sequenceDiagram participant D as Device (Daemon) participant S as TinySync Server participant B as Blob Store D->>S: GET /vaults/{id}/snapshot S-->>D: Snapshot { at_seq: N, items: [...], min_retained_seq } note over D: Store all items in local SQLite. Set last_seq_processed = N. loop For each non-deleted file (eager adapter) D->>B: GET /blobs/{content_hash} B-->>D: file bytes D->>D: write to local filesystem end note over D: Lazy adapter: register placeholders only. D->>S: GET /vaults/{id}/log?after=N S-->>D: LogPage { events, has_more } loop Replay events in seq order D->>D: apply_event → update SQLite + filesystem D->>D: last_seq_processed = event.seq end note over D: Engine enters OnlineIdle. Open WebSocket for wake hints.

10 / Normal Sync Cycle

After bootstrap, the engine runs sync_once on every trigger — WebSocket wake, file watcher notification, or timer. Three phases, always in this order.

Normal Sync Cycle
sequenceDiagram participant WS as WebSocket Wake participant D as Daemon participant S as Server participant B as Blob Store participant FS as Local Filesystem WS->>D: Changed { latest_seq: M } note over D: Trigger sync_once. note over D,S: Phase 1 — Pull remote changes D->>S: GET /log?after=last_seq_processed S-->>D: LogPage { events } loop Each event in seq order D->>D: upsert item in SQLite alt Created/Updated (eager) D->>B: GET /blobs/{hash} B-->>D: bytes D->>FS: write (temp → atomic rename) else Created/Updated (lazy) D->>FS: update placeholder metadata else Deleted D->>FS: remove file or folder else MovedRenamed D->>FS: move/rename path D->>D: rederive descendant paths else DeleteSubtree D->>FS: remove subtree atomically end end note over D,FS: Phase 2 — Detect local changes D->>FS: enumerate_local() FS-->>D: Vec<LocalEntry> D->>D: diff vs prior scan → Vec<LocalChange> D->>D: enqueue → pending_ops in SQLite note over D,S: Phase 3 — Submit pending ops loop Each pending op (creation order) alt File create or modify D->>FS: read_local_content(path, hash) D->>B: PUT /blobs/{hash} end D->>S: POST /mutations { op_id, base_item_version, ... } alt Accepted S-->>D: { accepted: true, seq, event } D->>D: complete_pending_op, apply event else Rejected — stale base S-->>D: { accepted: false, conflict: StaleBaseItemVersion } D->>FS: preserve_conflict_copy(path, op_id) D->>D: mark_pending_conflict end end
WebSocket is a hint, not a correctness path
The wake message carries only latest_seq. The client never applies it directly — it always fetches GET /log?after=N and replays in seq order. A dropped wake message is harmless. Correctness lives in the log.

11 / The Mutation Contract

Every change a client proposes is a typed mutation request that the server may accept or reject.

Mutation Types

enum MutationRequest {
    CreateFile {
        op_id, parent_item_id, item_id, name,
        content_hash, size          // blob must already be uploaded
    },
    CreateFolder { op_id, parent_item_id, item_id, name },
    ModifyFile {
        op_id, item_id,
        base_item_version,          // optimistic concurrency precondition
        content_hash, size
    },
    Delete  { op_id, item_id, base_item_version },
    MoveRename {
        op_id, item_id, base_item_version,
        to_parent_item_id, new_name  // atomic — never decomposed into delete + create
    },
}

Server Acceptance Conditions

  • Device credential is valid and not revoked.
  • op_id is new, or exactly repeats a prior operation (idempotent replay).
  • Name passes cross-platform validation.
  • No live sibling with the same normalized name at the target parent.
  • base_item_version matches the current server version for existing-item mutations.
  • Parent folder exists and is not deleted.
  • File content blob is already present in the blob store for file mutations.

Idempotency

Every mutation carries an op_id (UUID generated by the client, persisted to SQLite before sending). If the network fails after the server commits but before the client receives the response, the client retries with the identical op_id. The server returns the original result from the idempotency_keys table.

// Guarantees exactly-once effect despite retries
op_id = OpId::new()
persist_pending_op(op_id, request)   // written to SQLite FIRST
upload_blob_if_needed(content)
response = submit_mutation(request)  // safe to retry with same op_id
if response.accepted:
    complete_pending_op(op_id)
Upload-then-mutate ordering
File content is uploaded before the metadata mutation is submitted. Retrying a failed mutation after a successful upload is safe (blob PUT is idempotent). Two devices uploading identical content share one blob — no extra storage cost.

12 / Conflict Resolution

Not "last writer wins"

TinySync's model is more precise: first writer whose base version is still current wins. base_item_version on every existing-item mutation is an optimistic concurrency precondition. If Device A and B both start from version 5, and A's mutation arrives first (incrementing to version 6), B's mutation with base_item_version=5 is rejected — not silently overwriting A's confirmed write.

Conflict Scenario — Two Concurrent Modifies
sequenceDiagram participant A as Device A participant S as Server participant B as Device B note over A,B: Both have report.pdf at item_version=5 A->>S: ModifyFile { base=5, hash=hashA, op_id=opA } S-->>A: { accepted: true, seq: 101, item_version: 6 } B->>S: ModifyFile { base=5, hash=hashB, op_id=opB } S-->>B: { accepted: false, conflict: StaleBaseItemVersion } note over B: Preserve conflict copy → "report (TinySync conflict dev8b op …).pdf" B->>S: GET /log?after=last_seq S-->>B: event: Updated { item_version: 6, hash: hashA } note over B: Apply server version at original path. Queue conflict copy as new CreateFile. B->>S: CreateFile { name: "report (conflict...)", hash: hashB } S-->>B: { accepted: true } note over A,B: Both versions exist on server. All devices converge.

Conflict Policy (v1)

The policy is pluggable — resolve_conflict returns a list of actions and can be swapped without touching the engine:

  1. Accept the server's current version at the original item path.
  2. Rename local losing content to <name> (TinySync conflict <device> op <op_id>).<ext>.
  3. Reclassify the conflict file as a fresh local create and upload it.
  4. Both versions survive. All devices converge. No data is lost.
Conflict preservation also guards remote-delete-during-modify
If Device B has modified a file and Device A deletes it, the same mechanism fires: B's content is preserved as a conflict copy before the local file is removed.

13 / Engine Lifecycle States

The engine has five states, persisted in the engine_state SQLite table. Transitions are explicit and logged.

Engine Lifecycle State Machine
stateDiagram-v2 [*] --> Bootstrapping : process start Bootstrapping --> OnlineIdle : snapshot + log complete OnlineIdle --> OnlineSyncing : changes_pending OnlineSyncing --> OnlineIdle : queue_drained OnlineIdle --> Offline : connection_lost OnlineSyncing --> Offline : connection_lost Offline --> OnlineSyncing : reconnect OnlineIdle --> PanicResync : integrity_violation OnlineSyncing --> PanicResync : integrity_violation Offline --> PanicResync : local_state_corrupt PanicResync --> Bootstrapping : local_index_rebuilt
StateDescription
BootstrappingFetches server snapshot, enumerates local adapter state, reconciles, replays log events after snapshot seq. Also the entry state after panic-resync.
OnlineIdleFully caught up. Listening on WebSocket for wake notifications.
OnlineSyncingActively processing: replaying remote log, hashing local changes, uploading blobs, submitting mutations, satisfying hydration requests.
OfflineServer unreachable. Local changes queue in pending_ops. Remote changes fetched on reconnect via GET /log?after=last_seq_processed.
PanicResyncLocal state suspected corrupt. Engine halts. Recovery: fetch fresh snapshot, hash local files, preserve divergent content as conflict copies, rebuild SQLite from scratch. Re-runnable if interrupted.

14 / OS Adapter Layer

The OS native provider layer is where files appear in Finder and Explorer, and where lazy hydration happens. It is the most novel architectural feature relative to a traditional sync system.

Eager — FolderWatcherAdapter
Remote file bytes downloaded immediately on create/update. Uses Rust notify for filesystem watching. Prototype and CLI only; not the production experience.
Lazy — File Provider / CFAPI
On remote create, only metadata is registered as a placeholder. Content bytes fetched on demand when the user opens the file. Only opened files consume disk space. This is the product experience.

Adapter Methods

MethodCalled whenResponsibility
apply_createRemote creates an itemWrite bytes (eager) or register placeholder (lazy); create directory for folders
apply_updateRemote modifies a fileReplace content (eager) or invalidate placeholder (lazy); suppress own-write echo
apply_deleteRemote deletes an itemRemove file or directory from local filesystem
apply_move_renameRemote moves or renamesMove/rename at OS level; suppress echo
enumerate_localEngine reconcilingReturn all current local items with kind, hash, size, and optional inode-based identity
read_local_contentEngine uploading local changeReturn bytes at path; verify against expected hash
preserve_conflict_copyDestructive remote change over dirty local bytesCopy local file to conflict-named sibling; return None if source vanished
A — macOS File Provider
Lazy · Replicated model · Swift extension + Rust daemon IPC
Uses the modern replicated File Provider API (macOS 12+). Extension is thin — receives OS enumeration and hydration callbacks and relays them to the daemon over a Unix domain socket. Daemon owns SQLite and credentials; extension owns the OS callback lifecycle. Mounted under ~/Library/CloudStorage/TinySync/ (Apple mandates this; Dropbox fought it and lost). Hydration callbacks must complete or be failed within a deadline.
B — Windows Cloud Filter API (CFAPI)
Lazy · Placeholder filesystem · Thin provider process + named pipe IPC
Registers a sync root with CfRegisterSyncRoot. Manages placeholder states: online-only, partially-hydrated, fully-hydrated, pinned, unpinned. Hydration callbacks may request byte ranges — not always whole-file. Kept as a separate process to isolate shell integration failures. Antivirus and indexers routinely trigger hydration; the adapter handles this gracefully.
Own-write suppression
When the engine writes a remote update to disk, the filesystem watcher or OS callback fires for that write. The adapter must suppress this echo — otherwise the engine thinks the user created a new local change and triggers an upload loop. Suppression uses operation tokens, item IDs, content hashes, and short-lived in-flight records.

15 / Auth & Device Model

TinySync has no traditional user account. Authentication is device-scoped — one identity per physical machine — and authorization is group membership, resolved at request time.

Device Registration & Grant Flow
sequenceDiagram participant New as New Device participant Admin as Admin (or Platform Service) participant S as TinySync Server New->>S: POST /v1/devices { display_name } S->>S: create device record, hash credential S-->>New: { device_id, device_token: "tsdev_..." } note over New: Store token securely. Registration grants no vault access. Admin->>S: PUT /v1/groups/{gid}/devices/{device_id} Admin->>S: PUT /v1/groups/{gid}/vaults/{vault_id} note over S: Device now reaches the vault via the group edges. New->>S: GET /v1/devices/me/vaults S-->>New: [{ vault_id, ... }] New->>S: GET /v1/vaults/{id}/snapshot S-->>New: Snapshot { ... } note over New: Device is fully provisioned and syncing.
ConceptDescription
VaultSync scope. Named container of items with its own root and change log. Reachable by the devices whose groups it is granted to.
Device credentialRevocable bearer token (tsdev_…) — one per physical device, valid across every vault its groups reach. Hash stored at rest; raw token returned only at registration.
GroupOpaque authorization unit. Devices and vaults attach via group_devices/group_vaults edges; access is the union over a device's groups. Meaning lives in the Platform Service.
Device revocationRevoking a device kills that one credential everywhere — every vault at once. Other devices are unaffected. Per-vault removal is a group-membership edit, not a revocation.
Public file linksTime-limited, revocable, unauthenticated URLs. Linked to item_id (stable across renames). Server stores hash only; raw URL returned once at creation.

API surface

AreaEndpointsAuth
Vault lifecyclePOST /v1/vaultsAdmin
SyncGET /v1/vaults/{id}/snapshot · GET /v1/vaults/{id}/log · GET /v1/vaults/{id}/history · POST /v1/vaults/{id}/mutations · PUT/GET /v1/vaults/{id}/blobs/{hash} · GET /v1/vaults/{id}/events (WebSocket)Device (group membership)
SharingPOST/GET /v1/vaults/{id}/public-file-links · POST …/public-file-links/{link_id}/revoke · GET /p/{token}Device / unauthenticated download
Device identityPOST /v1/devices (registration; open by default) · GET /v1/devices · POST /v1/devices/{id}/revokeOpen or admin / admin / admin or the device itself
Device sessionGET /v1/devices/me/vaults · GET /v1/devices/stream (multiplexed wake stream) · GET /v1/vaults/{id}/devicesDevice
GroupsPUT/GET /v1/groups/{gid} · PUT/DELETE /v1/groups/{gid}/devices/{did} · PUT/DELETE /v1/groups/{gid}/vaults/{vid}Admin
No master user identity
The token model is intentionally user-agnostic. There is no "user account" that owns the vault. This keeps the auth surface small, makes device management deterministic, and lets the system attach to a parent identity platform later without redesigning the sync protocol.

16 / Critical Design Invariants

These are load-bearing. Violating any one breaks correctness, safety, or recoverability.

01
seq is the only ordering truth. Wall-clock timestamps are display metadata only. Log replay enforces strict sequential application — a gap causes an error, not a silent skip.
02
item_id is identity; path is derived. No code path uses a path as a primary key. Rename, move, and case-only renames update ancestry and rederive path caches. This is what makes folder rename correct.
03
WebSocket wake payloads are never applied directly. A wake message is a hint. The client always fetches GET /log?after=N and applies events in seq order. Correctness lives in the log, not the socket.
04
op_id is generated and persisted before the request is sent. The only way to guarantee idempotency across crashes and network failures. Generating it after a successful response is too late.
05
Blob must be uploaded before mutation is submitted. A file mutation references a content hash. The server validates the blob exists. The upload-then-mutate order is enforced in the engine, not assumed.
06
Local content is preserved before any destructive remote apply. If a file has dirty local changes and a remote delete or conflicting update arrives, preserve_conflict_copy is called first. Only after bytes are safe does the engine apply the remote change.
07
Move and rename are never decomposed into delete + create. A folder rename generating 1,000 fake file-level events is rejected. The engine expresses these as MoveRename mutations and MovedRenamed events — one operation regardless of tree size.
08
The engine does not own OS callbacks; the adapter does not own sync semantics. The PresentationAdapter boundary is strict. The engine never imports notify, File Provider, or CFAPI types. Adapters never import conflict policy or change-log sequencing.
09
Filesystem writes use temp-then-atomic-rename. Remote apply writes to a .tinysync-tmp-* path, then rename() to the final location. Prevents partially-written files from being visible or triggering false local-change detection.
10
Panic-resync is a conservative last resort, not a shortcut. On local invariant violation: fetch fresh snapshot, hash every local file, preserve divergent content as conflict copies, rebuild SQLite from scratch. The rebuild is ordered so it is safe to re-run after a mid-resync crash.

17 / Key Design Decisions

The decisions below shaped the architecture in ways that are not obvious from the structure alone. Each entry covers what was rejected and why — so future contributors don't re-litigate settled questions without new information.

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.

18 / v1 Scope

TinySync v1 is deliberately narrow. The bet is that one thing done bulletproof beats ten things done okay. The items below are not forgotten — they are explicitly deferred because they would delay a correct, shippable core.

Explicitly cut for v1

FeatureWhy deferred
Shared folders / collaborationAuth and per-folder permissions alone is months of work. Not on the critical path for a personal cloud drive.
Selective sync UISubsumed by on-demand hydration. If a file hasn't been opened, it hasn't been downloaded. The OS handles this transparently.
Version history / restoreTombstone retention covers recoverability. A full version UI requires storage policy, API surface, and UI complexity not on the critical path.
Block-level dedup / binary diffs / content-defined chunkingWhole-file content-addressing ships first. These are post-beta optimization work — they don't change the protocol shape.
LAN sync / peer-assistOptimization on top of the cloud-ordered log, not a topology change. Add it after the cloud path is proven.
E2E encryptionComplicates server-side dedup, conflict diagnostics, and abuse handling. Only adopt if it becomes the core product thesis.
Bandwidth limiters / schedulesConfiguration knobs, not core correctness. Add when users ask.
Mobile clientsDifferent OS APIs (iOS Files app, Android Storage Access Framework) and different lifecycle. A separate effort.
Predictive prefetchAn aesthetic layer above correct lazy hydration. Build after hydration is reliable.
Conflict merge UIOptimistic concurrency with conflict-copy preservation covers v1. A merge UI can layer on the same storage model later.
Trash / undeleteTombstone retention (90 days) covers recoverability. A trash UI with restore requires additional storage policy and API surface.
Multi-user accounts on one deviceOutside the token model. A device identity is a (user × machine) session; a multi-user system requires a user layer above devices (the Platform Service).

Open questions — deliberately deferred

These are not decided. They are punted until they materially affect a decision that must be made now.

  • Object storage provider (R2 vs B2 vs raw S3) — abstracted behind the BlobStore trait. Pick when production hosting matters. The choice has no protocol implications.
  • Tombstone retention window — likely 90 days; decide before the first migration that touches tombstones. Configuration, not architecture.
  • File size cap above 50 MB — fine for v1. Revisit when video or large photos enter scope. The cap is a single constant; raising it has no structural implications.
  • E2E encryption — depends on product positioning, not technical readiness. The current architecture does not preclude it, but adding it requires rethinking server-side dedup and conflict diagnostics.
  • Sync semantics backlog — folder concurrency races, temp/lock file policy, debounce and write coalescing, large-file transfer strategy, cross-platform path edge cases, and provider retry deduplication are tracked separately. Known hardening work; not current blockers.
  • Production credential storage — the CLI and daemon store the device token as plaintext in identity.json under the state root with owner-only 0600 permissions. The packaged desktop app should move its copy into the platform keychain / credential manager.

Locked — do not reopen without new information

These were debated and decided. Reopening requires a genuinely new constraint, not just a new mood.

Cloud-drive location (not arbitrary folders) Cloud-centric (not mesh) Rust (not Go) Engine-first, native providers before beta Optimistic concurrency + conflict-copy preservation Capability tokens (not user accounts) item_id as identity, path derived Server seq as ordering truth Mac-first when adapters begin NFC + casefold at vault entry Idempotency via (device_id, op_id) Tauri is control plane only CLI remains a peer surface Native providers are beta-critical Thin extension + per-user daemon Bounded JSON IPC over local sockets Native-provider writes bypass PresentationAdapter Unified ClientEvent stream Public links target item_id (not path or blob)

19 / Local Mutations & Device Fanout

This section traces the complete lifecycle of a file change — from the moment a user saves a file on Device A to the moment it appears on Device B and C. There are two directions: local → cloud (mutations) and cloud → local (log replay). They are deliberately decoupled.

The two directions

DirectionTriggerMechanismOrdering guarantee
Local → Cloud File change detected by adapter Hash → upload blob → submit mutation with base_item_version Server assigns seq at commit time. First writer whose precondition holds wins.
Cloud → Local WebSocket wake hint, or poll timer GET /log?after=last_seq → replay events in order Strictly sequential by seq. A gap is an error, not a skip.

Local mutation path — step by step

Local Mutation — Full Path
sequenceDiagram participant FS as Local Filesystem participant A as Adapter (watcher / OS) participant E as SyncEngine participant DB as Local SQLite participant S as TinySync Server participant BS as Blob Store FS->>A: file change event A->>E: report_local_change(LocalChange::Modified) E->>DB: enqueue_pending_op(op_id, ModifyFile{...}) note over DB: op_id written BEFORE any network call E->>A: read_local_content(path, expected_hash) A->>FS: read bytes FS-->>A: content bytes A-->>E: bytes E->>BS: PUT /blobs/{content_hash} BS-->>E: 200 OK (idempotent — safe to retry) E->>S: POST /mutations { op_id, item_id, base_item_version, content_hash } alt Server accepts S->>S: validate preconditions S->>S: commit to items (bump item_version) S->>S: append to change_log (assign seq) S-->>E: { accepted: true, seq: N, event } E->>DB: complete_pending_op(op_id) E->>DB: set last_seq = N else Server rejects — stale base_item_version S-->>E: { accepted: false, conflict: StaleBaseItemVersion } E->>A: preserve_conflict_copy(path, op_id, device_id) E->>DB: mark_pending_conflict(op_id) end
op_id is the crash-safety contract
The op_id is written to pending_ops in SQLite before any network call. If the device crashes after the server commits but before the client receives the response, the engine retries on restart with the same op_id. The server looks it up in idempotency_keys and returns the original result — the mutation is not applied twice.

Server commit & fanout

When the server accepts a mutation it does three things in one transaction: updates the item record (bumping item_version), appends to change_log (assigning seq), and records the idempotency result. After the transaction commits, it triggers fanout.

Server Fanout — Postgres NOTIFY → WebSocket → All Devices
sequenceDiagram participant S as TinySync Server participant PG as PostgreSQL participant WH as Wake Hub participant B as Device B (online) participant C as Device C (offline) S->>PG: COMMIT (items + change_log + idempotency_keys) S->>PG: NOTIFY tinysync_wake, '{"vault_id":…,"latest_seq":N}' PG-->>WH: LISTEN fires WH->>WH: broadcast to all vault subscribers WH->>B: WebSocket: Changed { vault_id, latest_seq: N } note over C: Device C is offline — wake is never delivered B->>S: GET /vaults/{id}/log?after=last_seq S-->>B: LogPage { events: [Updated{seq:N, item}] } B->>B: apply_event → update SQLite + filesystem B->>B: last_seq_processed = N note over C: Later — Device C reconnects C->>S: GET /vaults/{id}/log?after=last_seq_C S-->>C: LogPage { all events since C's cursor } C->>C: replay events in order, update SQLite + filesystem
The wake message carries no correctness state
The WebSocket payload is only latest_seq — a hint that something changed. Devices never apply wake payloads directly. They always fetch GET /log?after=N and replay in seq order. A dropped or delayed wake message is harmless — the next poll or reconnect catches up. The WebSocket is a latency optimisation, not a correctness path.

Full end-to-end: A modifies, B and C receive

End-to-End Fanout — Three Devices
sequenceDiagram participant A as Device A participant S as Server participant B as Device B (online) participant C as Device C (offline) note over A: User saves report.pdf A->>S: PUT /blobs/{hashA} A->>S: POST /mutations { ModifyFile, base=5, op_id=X } S->>S: commit → seq=101, item_version=6 S-->>A: { accepted: true, seq: 101 } S--)B: WebSocket: Changed { latest_seq: 101 } note over C: offline — no wake delivered B->>S: GET /log?after=100 S-->>B: [Updated { seq:101, item_version:6, hash:hashA }] B->>S: GET /blobs/{hashA} S-->>B: file bytes B->>B: write to local filesystem → report.pdf updated note over C: reconnects later C->>S: GET /log?after=95 S-->>C: [seq:96…seq:101, including the update] C->>S: GET /blobs/{hashA} S-->>C: file bytes C->>C: replay all events → filesystem brought current

Concurrent writes from multiple devices

Multiple devices can submit mutations for the same item concurrently. The server is the arbiter. The first mutation whose base_item_version matches the current server state is accepted and assigned a seq. All others are rejected with StaleBaseItemVersion. The losing device's local content is preserved as a conflict copy (see §12).

This means the server never merges — it serialises. The seq is the total order across all vaults. Within a vault, item_version is the per-item optimistic lock.

Offline durability

When a device is offline, local changes continue to queue in pending_ops (SQLite). The device's local filesystem reflects the user's changes immediately. On reconnect:

  1. Check last_seq_processed against server's min_retained_seq.
  2. If within the retention window → incremental GET /log?after=N replay.
  3. If the cursor has fallen behind min_retained_seq (device was offline > 90 days) → full snapshot resync.
  4. After pulling remote changes, submit all queued pending_ops in creation order.
Pending ops may conflict after a long offline period
If Device A modified a file offline and Device B modified the same file while A was away, A's pending mutation will hit a stale base_item_version on submit. The conflict-copy policy applies exactly as in the online case — A's content is preserved, B's server-committed version wins the canonical path.

20 / Device Authentication & Access

TinySync uses a custom bearer token scheme — not JWT. The design is deliberately minimal: stateless parsing, one database lookup per request, and immediate per-device revocation without token expiry windows.

Why not JWT

JWT is self-contained — the server can validate a token without a database round-trip. This is its main advantage. But TinySync's threat model requires immediate per-device revocation: if a device is lost or compromised, its credential must be dead the moment an admin revokes it. JWT cannot provide this without a revocation list — which is a database lookup anyway. Since TinySync already hits Postgres on every sync API call, the lookup costs nothing extra. Self-contained claims provide no benefit and add unnecessary surface area.

Token anatomy

All tokens follow the same structure: a typed prefix, a stable identifier, and a high-entropy secret — joined by underscores.

Device token:   tsdev_{device_id}_{secret}
Public link:    {secret}   (bare base64url, no prefix)
Admin token:    static value from TINYSYNC_ADMIN_TOKEN (env)

The secret in every case is 32 random bytes, base64url-encoded without padding — giving a 43-character string. The server generates this with thread_rng().fill_bytes() (device) or OsRng.fill_bytes() (public links, where the stronger CSPRNG is preferred since the token is the only auth factor).

The server never stores the raw secret. It stores:

SHA-256("tinysync:v1:{kind}:" || secret_bytes)

The domain prefix (tinysync:v1:device:, tinysync:v1:public_link:, etc.) ensures the same raw bytes produce a different hash for different token kinds — preventing cross-kind token confusion even if secrets were ever reused.

Token types & properties

TokenFormatTTLUsesPurpose
Device tsdev_{uuid}_{43-char} Until revoked Unlimited All sync API calls. One per physical device; vault reach is resolved from group membership per request.
Public link {43-char} Configurable or none Unlimited Unauthenticated file download. Linked to item_id (stable across renames).
Admin Static env var Unlimited Server-level: vault creation, group management, device list/revocation, registration when closed. Never distributed to clients.

Request authentication flow

Per-Request Auth — Device Token Validation
sequenceDiagram participant D as Device participant S as TinySync Server participant PG as PostgreSQL D->>S: GET /vaults/{id}/log Authorization: Bearer tsdev_{device_id}_{secret} S->>S: parse_device_token() → { device_id, secret } S->>PG: SELECT credential_hash, revoked_at FROM devices WHERE device_id = ? PG-->>S: { credential_hash, revoked_at: null } S->>S: SHA-256("tinysync:v1:device:" || secret) == credential_hash ? S->>S: revoked_at IS NULL ? S->>PG: EXISTS (group_devices ⋈ group_vaults) for (device_id, vault_id) ? PG-->>S: true alt Valid, not revoked, vault reachable S-->>D: 200 OK LogPage { ... } else Hash mismatch S-->>D: 401 Unauthorized else Device revoked S-->>D: 403 Forbidden "device is revoked" else No group path to vault S-->>D: 403 Forbidden "device is not authorized for vault" end

Device lifecycle

Device Lifecycle
stateDiagram-v2 [*] --> Registered : POST /v1/devices issues the device credential Registered --> Active : admin grants group membership (PUT group-device edge) Active --> Active : normal sync operations Active --> Revoked : POST /v1/devices/…/revoke (admin or the device itself) Revoked --> [*] : credential permanently invalid — re-enabling means a new registration

Registration flow — adding a new device

Registration replaces the old join-token exchange. It issues an identity, not access — a freshly registered device reaches nothing until an admin grants it group membership.

  1. The device calls POST /v1/devices { display_name }. Open by default; with TINYSYNC_OPEN_DEVICE_REGISTRATION=false the admin token is required (the Platform-Service-mediated mode).
  2. The server creates the device record, stores the credential hash, and returns the raw device token once.
  3. An admin (or the Platform Service) grants access: PUT /v1/groups/{gid}/devices/{did} and PUT /v1/groups/{gid}/vaults/{vid}.
  4. The raw device credential is stored by the client in identity.json under the state root with 0600 permissions. It is never sent to the server again as a raw value — only used to construct request headers.
Production credential storage is not yet hardened
The CLI and daemon store the device token as plaintext in <state-root>/identity.json with owner-only 0600 permissions. This is acceptable for CLI and prototype use; the packaged desktop app should move its copy into the platform keychain (macOS Keychain, Windows Credential Manager) before beta.

Revocation

Revoking a device sets revoked_at on its record — the row and its credential hash are retained so change-log attribution survives. Every subsequent request from that device returns 403 "device is revoked". Revocation is global: the one credential dies across every vault at once. No other device is affected. Removing a device from a single vault is a group-membership edit (DELETE /v1/groups/{gid}/devices/{did}), not a revocation.

If a device creates public share links, those can be bulk-revoked at the same time: POST /v1/devices/{id}/revoke accepts a flag revoke_public_links_created_by_device: true. This is the intended response to a compromised device.

Security properties

PropertyHow it holds
Secret confidentialityServer stores only SHA-256(prefix + secret). A Postgres breach does not expose raw tokens.
Immediate revocationEvery request checks revoked_at. No expiry window. No need for revocation lists.
Cross-kind confusion preventionDomain separator (tinysync:v1:{kind}:) in the hash input ensures a device secret cannot be used as a public-link token even if the raw bytes match.
Registration grants nothingPOST /v1/devices issues an identity, not access. An unprovisioned device reaches zero vaults until an admin adds it to a group with vault grants.
No vault-wide rotationDevice credentials are per-device. Revoking one device has zero impact on others.
Public links are bearer secretsRaw URL returned once at creation. Server stores only the hash. Lost link cannot be recovered — must be revoked and recreated.

21 / Drive as a Service — Platform Integration

Status: Proposed Architecture — Partially Superseded
This section is a design brainstorm, recorded to make the integration model explicit before it is needed. Since it was written, the identity half has shipped in TinySync core (migration 0004): groups and membership edges now live inside TinySync (see §15 and §20), so the join-token orchestration and per-(device × vault) token provisioning sketched below are superseded — access grants are now PUT /v1/groups/* edge writes. The user layer, container policies, and web view layer remain unbuilt proposals.

TinySync is a sync primitive — it understands vaults and devices. It does not understand users, teams, projects, or products. This is intentional. The goal of "Drive as a Service" is to define the thinnest possible interface that lets ecosystem products leverage TinySync without forcing TinySync to absorb identity concerns it should not own.

The three-layer model

System Layers — Drive as a Service
graph TB subgraph Products["Ecosystem Products"] P1["Tasket"] P2["Notes"] P3["Any Product"] end subgraph Platform["Drive Platform Service · NEW LAYER"] direction TB UM["User & Group Management"] AM["Access Management (user → vault grants)"] TM["Token Orchestration (device × vault → device token)"] PM["Container Policy Store (syncignore, limits, settings)"] WV["Web View Layer (file browser, activity, sharing)"] end subgraph TinySync["TinySync Core · UNCHANGED"] V["Vault lifecycle"] D["Device registration + revocation"] S["Sync protocol + change log"] B["Blob storage"] end P1 & P2 & P3 -- "Product SDK / REST API" --> Platform Platform -- "Admin API (create vault, join tokens, revoke)" --> TinySync style Products fill:#f7f7f5,stroke:#c9c9c7 style Platform fill:#eff6ff,stroke:#93c5fd style TinySync fill:#f0fdf4,stroke:#86efac

Ownership boundary

ConceptOwned byRationale
Vaults, change log, blobs, sync protocolTinySync CoreThe sync primitive. Must stay stable.
Devices, device tokens, revocationTinySync CoreSync identity. Device is the right granularity for auth.
UsersDrive Platform ServiceUser identity varies per ecosystem — SSO, OAuth, magic links. TinySync should not care.
Groups / Teams / ProjectsDrive Platform ServiceOrganizational constructs that map to sets of users. Not a sync concept.
User → Vault access grantsDrive Platform ServiceAccess policy, not sync mechanics. Expressed to TinySync as device registrations.
Group → User fan-outDrive Platform ServiceThe Platform resolves group membership to users, then to devices, then provisions tokens.
Container tags + policiesDrive Platform ServiceProduct-level namespacing and configuration. TinySync carries the tag; Platform interprets it.
Web views, sharing UIDrive Platform ServiceRead TinySync APIs; render product-specific experiences.

Data model — which tables live where

Data Model Layers
graph LR subgraph TS["TinySync Core DB (Postgres)"] V["vaults (vault_id, container_tag, root_item_id)"] DEV["devices (device_id, display_name, credential_hash, revoked_at)"] TSG["groups (group_id, display_name)"] TGD["group_devices (group_id, device_id)"] TGV["group_vaults (group_id, vault_id)"] CL["change_log (vault_id, seq, ...)"] BL["blobs (content_hash, ...)"] end subgraph PL["Platform Service DB"] US["users (user_id, email, external_idp_id)"] GR["groups (group_id, name)"] GM["group_members (group_id, user_id)"] UD["user_devices (user_id, device_id, device_name, platform)"] UV["user_vault_grants (user_id, vault_id, role, granted_at)"] GV["group_vault_grants (group_id, vault_id, role, granted_at)"] CT["containers (container_tag, product_id, display_name)"] CP["container_policies (container_tag, policy_json)"] UV2["vault_metadata (vault_id, container_tag, display_name, created_by)"] end TSG --- TGD TSG --- TGV TGD --- DEV TGV --- V US --- UD US --- UV GR --- GM GR --- GV GM --- US UD -.->|device_id foreign ref| DEV UV -.->|vault_id foreign ref| V GV -.->|vault_id foreign ref| V CT --- CP CT --- UV2 UV2 -.->|vault_id foreign ref| V

Access model — three levels of fan-out

Access can be granted at any of three levels. The Platform Service is responsible for expanding higher-level grants downward to devices.

Access Fan-Out: Group → User → Device → Vault Token
graph TD G["Group e.g. 'Engineering Team'"] U1["User A 3 devices"] U2["User B 2 devices"] D1["MacBook Pro device_id: aaa"] D2["Windows PC device_id: bbb"] D3["iPhone device_id: ccc"] D4["MacBook Air device_id: ddd"] D5["iPad device_id: eee"] T1["device token tsdev_aaa_..."] T2["device token tsdev_bbb_..."] T3["device token tsdev_ccc_..."] T4["device token tsdev_ddd_..."] T5["device token tsdev_eee_..."] VT["Vault vault_id: xyz"] G --> U1 & U2 U1 --> D1 & D2 & D3 U2 --> D4 & D5 D1 --> T1 --> VT D2 --> T2 --> VT D3 --> T3 --> VT D4 --> T4 --> VT D5 --> T5 --> VT style G fill:#eff6ff,stroke:#93c5fd style U1 fill:#eff6ff,stroke:#93c5fd style U2 fill:#eff6ff,stroke:#93c5fd style VT fill:#f0fdf4,stroke:#86efac

TinySync sees only the bottom two rows — devices and device tokens. The group and user structure above that is entirely the Platform Service's concern.

Token orchestration — provisioning device × vault access

The fundamental operation: given a (user, vault) grant, ensure every device that user owns has a valid device token for that vault. This is called token provisioning.

Token Provisioning — Group Grant to Device Token
sequenceDiagram participant P as Product (Tasket) participant PL as Platform Service participant TS as TinySync Core participant Dev as Device (User A, MacBook) P->>PL: PATCH /groups/{id}/vaults { vault_id, role: "member" } PL->>PL: expand group → users → devices note over PL: For each (device, vault) pair not yet provisioned: PL->>TS: POST /vaults/{id}/join-tokens { ttl: 24h, max_uses: 1 } TS-->>PL: { join_token: "tsjoin_..." } PL->>PL: store pending_provision(device_id, vault_id, join_token) note over Dev: Device checks in (next sync cycle or app open) Dev->>PL: GET /me/pending-vaults Authorization: Bearer {session_token} PL-->>Dev: [{ vault_id, join_token, container_tag, policy }] Dev->>TS: POST /join { join_token, device_name } TS-->>Dev: { device_token: "tsdev_..." } Dev->>Dev: store device_token for vault_id Dev->>PL: POST /me/provisioned { device_id, vault_id } PL->>PL: mark provision complete
Lazy vs eager provisioning
The sequence above is lazy — tokens are provisioned when the device next checks in, not at grant time. This handles offline devices naturally. The alternative is eager provisioning (generate all tokens at grant time), which is simpler but wastes join tokens if devices never connect. Lazy is recommended; eager can be offered as an optimisation for latency-sensitive flows.

Vault creation — product-driven

Ecosystem products create vaults via the Platform Service, not directly against TinySync. The Platform Service calls TinySync's admin API and records metadata on both sides.

Vault Creation Flow — Product → Platform → TinySync
sequenceDiagram participant P as Product (Tasket) participant PL as Platform Service participant TS as TinySync Core P->>PL: POST /vaults { name: "Project Alpha", container_tag: "tasket:project", owner_group_id: "eng-team" } PL->>PL: validate product credentials PL->>TS: POST /vaults { container_tag: "tasket:project" } — platform admin token TS-->>PL: { vault_id: "abc-123", root_item_id: "..." } PL->>PL: INSERT vault_metadata(vault_id, name, container_tag, owner_group_id) PL->>PL: INSERT group_vault_grants(eng-team, vault_id, role=owner) PL->>PL: trigger token provisioning for all group members' devices PL-->>P: { vault_id: "abc-123", ... }

Container tags and policy layer

A container is a product's namespace within Drive. Every vault created by a product carries that product's container tag (e.g. tasket:project, notes:personal). The tag is stored on the vault in TinySync and is visible in the change log, enabling per-product behaviour without TinySync understanding product semantics.

Container-level policies are owned by the Platform Service. They are delivered to devices as part of the vault bootstrap configuration — alongside the device token, not inside TinySync's sync protocol.

Policy typeWhere storedHow deliveredWho enforces
syncignore rulesPlatform: container_policiesIn vault bootstrap response from PlatformClient SDK — files matching rules are not uploaded
Max file sizePlatform (+ TinySync enforces at mutation)Bootstrap configBoth: client skips; server rejects oversized
Allowed file typesPlatform: container_policiesBootstrap configClient SDK
Retention / archivalPlatformServer-side job; not delivered to clientsPlatform Service
Sharing permissionsPlatform: vault grantsEmbedded in access grantPlatform (controls who can generate share links)
TinySync only needs to know one policy: max file size
All other policies are client-side conventions enforced by the SDK before a mutation is even submitted. TinySync's server only hard-enforces the file size cap (currently 50 MB) because exceeding it is a protocol error. Everything else — syncignore, file type filters — is the Platform's concern.

Web view layer

The Platform Service hosts web experiences per product. A web session authenticates against the Platform (not TinySync directly) and receives a short-lived session token scoped to the user's accessible vaults. The web app calls the Platform's read-through API, which proxies to TinySync for file tree and history data.

Web View Architecture
graph LR Browser["Browser (Product web app)"] PL["Platform Service (session auth, read-through API)"] TS["TinySync Core (snapshot, log, blob download)"] Browser -- "session token" --> PL PL -- "file tree, activity" --> TS PL -- "direct blob URL or proxied download" --> Browser TS -- "file bytes" --> PL

Web sessions use session tokens (tssess_...) — not device tokens. A browser is not a sync device; it gets read access to the vault's current snapshot and history, not the sync protocol.

Access revocation and group membership changes

When a user's access changes (leaves a group, vault access revoked), the Platform Service must revoke their devices' TinySync credentials for the affected vaults. This is a cascade the Platform owns entirely — TinySync just executes the revocations.

EventPlatform actionTinySync action
User removed from groupDelete group_members row. Compute vaults user no longer has access to via other grants.DELETE /vaults/{id}/devices/{device_id} for each affected (device × vault)
Vault access revoked for groupDelete group_vault_grants. Expand to all users and devices with no other path to this vault.Bulk revoke affected devices from vault
User account deletedDelete all user grants. Compute all vaults with no remaining grants.Revoke all user devices from all vaults
Device deregistered by userDelete user_devices row.DELETE /devices/{device_id} — revokes from all vaults simultaneously

Open questions

  • Platform admin credential model. The Platform Service needs an admin-level credential to call TinySync (create vaults, generate join tokens, revoke devices). Should this be a static admin token per environment, or a short-lived service token per operation? Static is simpler; short-lived is safer.
  • Eager vs lazy provisioning. Lazy handles offline devices but adds latency (device must check in before it can sync a newly granted vault). Eager is instant but burns join tokens. A hybrid — eager for online devices, lazy fallback — may be the right default.
  • Group membership change propagation time. When a user is removed from a group, how quickly must their TinySync access be revoked? Real-time revocation requires the Platform to call TinySync synchronously. Eventual (next sync cycle) may be acceptable depending on the product's threat model.
  • Multi-vault device bootstrap. A device may have access to many vaults (personal, multiple team projects). Should the device maintain one SQLite engine per vault (current model) or a shared engine with vault partitioning? Current model works but may need UX to manage which vaults are synced on which device.
  • Policy versioning. Container policies will evolve. When a policy changes (e.g. syncignore rules tightened), already-synced files that violate the new policy need handling. Define a policy version in bootstrap config; client re-evaluates on policy version bump.
  • Web blob access. Should the web app download blobs directly from TinySync's blob endpoint (efficient, requires a valid token) or via a Platform-proxied URL (allows Platform to enforce per-file permissions)? Direct is simpler; proxied gives finer-grained access control.
  • Shared devices. If two users share a physical device (family Mac), each user needs separate device registrations per vault. The client must support multiple vault credentials on one device and associate each with the appropriate user session.

What TinySync needs to add to support this

Almost nothing. The design intentionally keeps TinySync unchanged. The only additions that would help:

  • Batch join token creation. Provisioning tokens one at a time when a large group is granted access is slow. A single POST /vaults/{id}/join-tokens/batch endpoint with a count parameter would help the Platform Service.
  • Batch device revocation. Similarly, revoking all devices for a user across all vaults in one call is cleaner than N individual revocations.
  • Container tag filtering on vault listing. GET /vaults?container_tag=tasket:project — useful for the Platform to enumerate vaults it owns without a full scan.
  • Webhook on device sync activity. Optional. Lets the Platform Service track last-seen-at per device without polling.

The sync protocol, change log, blob store, and conflict model require zero changes. Drive-as-a-service is a thin API layer above an unchanged sync engine.