# SyncKit Client SDK, Architecture ## Overview The SyncKit Client SDK (`synckit-client`) is a Rust crate that provides end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull changelog entries without the server ever seeing plaintext data. Version: 0.5.0. ## Crate structure ``` src/ lib.rs Crate root. Re-exports public types, quick-start doc example. client/ SyncKitClient struct and all HTTP methods, split across submodules (auth, sync, blob, encryption, rotation, subscribe, subscription, helpers). Retry logic and token expiry detection live here. crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob encrypt/decrypt, ZeroizeOnDrop guard. error.rs SyncKitError enum (thiserror, #[non_exhaustive]): Http, Server, Json, NoMasterKey, DecryptionFailed, IntegrityFailed, InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired, InvalidArgument, Internal, and Keychain (feature-gated behind `keychain`). keystore.rs OS keychain integration (macOS Keychain, Linux secret-service, Windows Credential Manager) via the `keyring` crate. Feature-gated behind `keychain` (default on). No-op stubs when disabled. types.rs Request/response types matching the server wire protocol. Public types: ChangeEntry, ChangeOp, Device, SyncStatus, BlobUploadUrlResponse. Internal types: auth, push/pull, key, OAuth, blob requests. Public re-exports from lib.rs: SyncKitClient, SyncKitConfig, SessionInfo, ChangeEntry, ChangeOp, Device, SyncStatus, SyncKitError, Result ``` ## Key lifecycle ``` password | v Argon2id (64 MB, 3 iterations, parallelism 1) + random 32-byte salt (stored in envelope) | v wrapping_key (256-bit) | v XChaCha20-Poly1305 encrypt/decrypt | v master_key (256-bit, randomly generated on first device) | v XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt ``` - **First device**: generates a random master key, wraps it with the password, pushes the encrypted envelope to the server, caches plaintext in OS keychain. - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps with the password, caches in OS keychain. - **Subsequent launches**: loads the master key directly from the OS keychain (no password prompt, no server call). - **Password change**: decrypts master key with old password, re-wraps with new password (fresh random salt), pushes new envelope to server. The master key itself does not change, so existing ciphertext remains valid. ## Authentication flow ### Email/password 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key. 2. Server validates credentials and returns a JWT, user ID, and app ID. 3. Client stores the session in an internal `Mutex>`. ### OAuth2 PKCE 1. Caller generates a PKCE code verifier and challenge. 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge. 3. Caller opens the URL in a browser and starts a localhost callback server. 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges the authorization code for a JWT via `/oauth/token`. 5. Session is stored identically to email/password auth. ### Session restoration `restore_session(token, user_id, app_id)` populates the session from stored credentials (e.g., OS keychain) without making any HTTP calls. The caller is responsible for checking `is_token_expired()` and re-authenticating if needed. ### Session utilities - `session_info()` returns `Option` (token, user_id, app_id) without making HTTP calls. Returns `None` if not authenticated. - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer. Returns `true` if no session exists or the token is about to expire. - `clear_session()` clears in-memory session and master key. Does not affect OS keychain (call `keystore::delete_key` separately). - `config()` returns a reference to the `SyncKitConfig`. ## Encryption setup flow ``` has_server_key()? | +-- false --> setup_encryption_new(password) | Generate master key, wrap, push envelope, cache in keychain | +-- true --> setup_encryption_existing(password) Pull envelope, unwrap with password, cache in keychain ``` On subsequent launches, `try_load_key_from_keychain()` loads the master key from the OS keychain without any server interaction or password prompt. ## Push/Pull protocol ### The HLC envelope Every change, **including Deletes**, is encrypted as a JSON *envelope*, not as the bare row data: ```json { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": } ``` Encrypting the envelope (rather than just the row) is what carries the hybrid logical clock (HLC) inside the E2E ciphertext, so the server can order entries by `seq` but never sees or orders by the clock. A Delete has `data: null` but still seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`. ### Wire version tag and AAD binding The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` = `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader always knows which AAD to apply: - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD. - untagged (legacy, pre-v2) → open with empty AAD. This means a malicious or compromised server cannot relocate a valid ciphertext to a different row or table: the AAD no longer matches and the open fails closed. Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts legacy entries into the v2 form opportunistically. The AAD is never passed as raw bytes. Callers name a context, `AeadContext::Entry { table, row_id }` or `AeadContext::Blob { hash }`, and the crypto layer derives the AAD from it. The byte-level builder is private, so an empty or mismatched AAD is not expressible at the call site. ### Envelope version dispatch (`__skver`) The decrypted entry payload is an envelope `{ __skver: 2, __skhlc, data }`. Splitting it back into `(hlc, data)` dispatches on the explicit `__skver` tag via a typed `WireVersion`: an unknown future version is a hard error, not a silent fall-through to a bare-row read. A tag-less payload carrying an embedded `__skhlc` is a gen-1 envelope; anything else is a legacy bare row. ### Chunked blob format (`sk3:`) Blobs use a chunked AEAD format so a reader decrypts and verifies one chunk at a time. Layout: `sk3:` `version(u8=3)` `chunk_size(u32 LE)` `total_len(u64 LE)` followed by sealed 1 MiB chunks. Each chunk's AAD binds `(content_hash, chunk_index, chunk_count)`, so reorder, truncation, duplication, and cross-blob substitution all fail closed. `blob_download` streams the body and decrypts chunk-by-chunk (peak memory is the plaintext plus one in-flight chunk, not ciphertext *and* plaintext together); legacy `sk2:`/untagged blobs still decrypt whole-buffer with no flag-day. ### Push 1. Caller provides a `Vec` with plaintext `data` fields and an HLC. 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the `sk2:`-tagged base64 string. 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a client-generated `batch_id` (idempotent push: a replayed batch returns the existing cursor). 4. Server appends entries to the changelog and returns the new cursor (i64). 5. Retries on transient failures (see below). ### Pull 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor. 2. Server returns changelog entries since that cursor, a new cursor, and a `has_more` flag for pagination. 3. Client decrypts each entry's envelope (auto-detecting the wire version) and returns plaintext `ChangeEntry` values, HLC included, to the caller. 4. Retries on transient failures. **Caller contract.** The SDK does not persist the cursor or dedup re-delivered changes. Persist the returned cursor only *after* applying the returned changes (same transaction where possible), and make apply idempotent per `(table, row_id, hlc)`, a reconnect can re-deliver a batch. ### Conflict resolution Conflicts are resolved client-side (the server cannot read plaintext). `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms, counter, node)` wins, so a newer edit beats an older delete and vice-versa, and every device converges on the same winner. An *exactly* equal HLC normally means a same-device echo; if two installs share a `node` UUID (cloned config), the tie is broken deterministically on the canonical payload bytes so both devices still converge. `detect_conflicts` only flags rows with an *un-pushed* local edit. Its clean set is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read the applicable entries out is `gated(committed_hlc)`, which drops any clean change older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch those clocks first.) This makes "apply a clean change without checking the committed clock", which would let an older remote edit clobber a newer local value, unrepresentable rather than a contract the caller has to remember. ### Sequence numbers The server assigns a monotonically increasing sequence number (`seq`) to each changelog entry. The cursor is the `seq` of the last entry the client has seen. Pulling with cursor 0 fetches from the beginning. ## Blob encryption Binary blobs (files, images, audio samples) are encrypted client-side before upload: 1. `blob_upload_url(hash, plaintext_size)` requests a presigned S3 PUT URL from the server. If the blob already exists (content-addressed by hash), no upload is needed. The declared size is converted to the ciphertext length before it is sent, because the server signs it as `Content-Length` and the PUT carries sealed bytes. 2. `blob_upload(hash, presigned_url, data)` encrypts the plaintext bytes with the master key, **binding the content hash as AEAD associated data**, and PUTs the ciphertext to S3. 3. `blob_confirm(hash, size)` tells the server the upload completed. Steps 1 and 2 are the in-memory path, for bytes with no file behind them. Every consumer app instead calls `blob_upload_streaming(hash, path)` (below), which covers the same ground with bounded memory and no size ceiling, then step 3. 4. `blob_download_url(hash)` requests a presigned GET URL. 5. `blob_download(expected_hash, presigned_url)` fetches the ciphertext, decrypts with the hash as AAD, then **re-verifies** that the plaintext hashes to `expected_hash` (returning `IntegrityFailed` otherwise). The AEAD tag proves the bytes were not forged; the re-hash proves the server served the bytes for *this* address, closing the substitution/rollback gap. Blob encryption uses `encrypt_bytes_aad`/`decrypt_bytes_aad` (raw bytes, no base64) to avoid the ~33% base64 overhead on large files. v2 overhead is 44 bytes per blob: a 4-byte `sk2:` wire tag + 24-byte XChaCha20 nonce + 16-byte Poly1305 tag. ### Large blobs: the multipart path Steps 1 and 2 buffer the whole ciphertext, and the server refuses a one-shot PUT above its own ceiling. For a blob already on disk, `blob_upload_streaming(hash, path)` replaces both with a multipart session and holds one part at a time: 1. It computes the exact ciphertext length from the file size alone (`blob_encrypted_len`) and declares it to `blobs/multipart/start`, which returns the part geometry both sides then derive identically. 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full part is ready, and PUTs each part to a presigned URL signed for that exact `Content-Length`. Each presigned part URL is requested on its own, one per part, since a part's checksum only exists once it has been sealed and only one part is ever held in memory. 3. It recomputes the SHA-256 as it reads, so a file that changed since the caller hashed it fails rather than landing under a stale content address. 4. Any failure past `start` aborts the session, since uploaded parts bill until released. `blob_confirm` is still the caller's next step, unchanged. It works at any size (a small blob is a single part), so prefer it whenever the blob is file-backed. Not yet resumable across a process restart: the session id is not persisted. ## Keychain integration The `keychain` feature (default on) uses the `keyring` crate (v4) to store the master key in the OS credential store: - **macOS**: Keychain Services - **Linux**: secret-service (D-Bus Secret Service API) - **Windows**: Windows Credential Manager Keychain entries are keyed by `synckit:` (service) and `` (account). When the feature is disabled, store/load/delete are no-ops that return `Ok`. ## Retry and resilience Push and pull use `retry_request()` with exponential backoff: - **Max retries**: 3 - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt) - **Transient errors** (retried): network failures (timeout, DNS, connection refused), server errors (5xx), rate limiting (429) - **Permanent errors** (not retried): client errors (4xx except 429), serialization errors, encryption errors, missing session/key Token expiry is detected client-side by decoding the JWT `exp` claim with a 30-second buffer. If the token is about to expire, the client returns `SyncKitError::TokenExpired` so the caller can re-authenticate before sending a request that would fail with 401. ## Key design decisions ### Why XChaCha20-Poly1305 - 192-bit nonces are large enough to generate randomly without realistic collision risk (unlike AES-GCM's 96-bit nonces). - Removes the need for a nonce counter or nonce-misuse resistance scheme. - Performance is comparable to AES-GCM on modern hardware. ### Why Argon2id - OWASP-recommended for password hashing. Resists both side-channel (Argon2i) and GPU brute-force (Argon2d) attacks. - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum. ### Why random salt per wrap Each `wrap_master_key` call generates a fresh 32-byte random salt. This means re-wrapping with the same password produces a completely different envelope, preventing precomputation attacks and ensuring password changes are cryptographically distinct. ### Why no token refresh The server currently issues short-lived JWTs without refresh tokens. The client detects expiry and returns `TokenExpired`, leaving re-authentication to the caller. This keeps the SDK stateless with respect to refresh logic and avoids storing long-lived credentials in memory. ## Security properties - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync entries, blobs). It never receives the plaintext master key or user data. - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to clear the master key from memory when the guard is dropped. - **No key material in logs**: Tracing statements log events (key stored, key loaded) but never log key bytes or ciphertext. - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks. - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with `(table, row_id)` as AEAD associated data, and blob ciphertext with the content hash; downloaded blobs are additionally re-hashed and rejected if they do not match their requested address. So the untrusted server cannot relocate or substitute a valid ciphertext without the open (or the hash check) failing closed. See "Wire version tag and AAD binding". - **Envelope versioning**: The key envelope includes a version field (`v: 1`) and travels with its Argon2 cost parameters, so the work factor can be raised later without breaking existing envelopes. --- # The `SyncStore` higher-level helper > Status: shipped in 0.6 behind the default-on `store` feature (`src/store/`). > Everything above describes the base SDK (transport + crypto + HLC + conflict > primitives); this section describes the layer *above* those primitives that > absorbs the SQLite plumbing the three first-party apps otherwise hand-write. > The design was reverse-engineered from the GoingsOn, audiofiles, and Balanced > Breakfast sync services; a consumer that only needs the transport/crypto SDK > (e.g. mnw-cli) sets `default-features = false` and skips it. ## Motivation Without this layer the SDK stops at "encrypt/decrypt a `Vec` and move it over the wire," and each consuming app writes ~700–1,000 lines of *identical* engine on top of it: a `sync_changelog` + `sync_state` schema, per-table triggers, a drain-loop push, a cursor-loop pull, an FK-ordered apply with JSON→SQL binding, HLC stamping, an initial snapshot, retention/cleanup, and a scheduler (tick + SSE + backoff + status events). Measured across the three apps this is the overwhelming majority of their sync code, and it is mechanical. The single largest boilerplate source is **column-list triplication**: every syncable table names its synced columns three or four times, in the trigger DDL (`json_object(...)`), in the initial-snapshot projection, and in the apply-side whitelist, kept in agreement only by hand and a round-trip drift test. When they drift, a column silently arrives NULL (this is a real shipped bug class). One declared column list should generate all of them. The goal of `SyncStore` is: an app **declares its tables and policy once**, and the engine owns everything mechanical. This is the "simple" pillar of the product (see the private roadmap), the differentiators (private, secure, economical) are already in the 0.6 primitives; this layer is what makes them easy to adopt. ## What is generic (absorbed) vs. what is policy (declared) The three apps agree, byte-for-byte in spirit, on all of this, it moves into the engine: - `sync_state` KV + the transactional `applying_remote` echo-suppression flag. - `sync_changelog` schema, the `pushed` flag, cursor bookkeeping. - Push: HLC-stamp unpushed rows, batch ≤500, map to `ChangeEntry`, `client.push`, mark pushed, skip-and-retain corrupt/unknown-op rows, drain until empty. - Pull: paginated `client.pull_rich` loop, apply-then-persist-cursor (the SDK's documented crash-safe contract), auto-decrypt. - Apply: partition into upserts/deletes, iterate the declared table order (parents-first upserts, children-first deletes), JSON→SQL type coercion, per-row constraint-violation *skip-and-log* (a poison row never wedges the cursor), HLC gating against committed clocks, `resolve_lww` for true conflicts. - Initial snapshot, `cleanup_changelog` (prune pushed > N days), changelog retention cap, pending-change count. - The scheduler skeleton: 60 s tick raced against the SSE `changed` stream, the auth/key/enabled gate chain, a shared sync mutex, exponential backoff, and the 402 → subscription-required special case. - Device registration and the `perform_sync` orchestration order. Everything an app genuinely varies is small and enumerable. It becomes a declarative `SyncSchema` plus a couple of trait hooks: | Policy | Seen in | Expressed as | |---|---|---| | Which tables, their columns, FK order | all | `SyncSchema { tables: [SyncTable…] }`, declaration order = FK order | | Primary key shape (single / composite) | BB tag tables, AF `tags` | `PrimaryKey::{Single, Composite}` | | Partial-column, UPDATE-only sync | BB `feed_items` (is_read/is_starred) | `SyncMode::PartialUpdate { set }` | | Ignore remote deletes | BB `feed_items` | `DeleteMode::Ignore` | | Soft-delete (tombstone) instead of hard | AF `samples.deleted_at` (CASCADE safety) | `DeleteMode::Tombstone { column }` | | Preserve local-only columns on upsert | GO `email_accounts` credentials | `preserve_local: &[col]` | | Column defaults on first insert | GO `password=''` for NOT NULL | `insert_defaults: &[(col, val)]` | | Row references an *unsynced* table (relax FK) | GO `tasks.source_email_id` | `references_unsynced: true` | | Row-id privacy hashing on the wire | AF content tables (`hash_row_id(salt,…)`) | `RowIdScheme::Hashed` | | Exclude some rows/keys from sync | AF `user_config` `sync_*`, `loose_files` | `exclude_where: Option<&str>` (a SQL predicate, see AF example; must be SQL because it also compiles into the generated trigger's WHEN clause, not just the apply guard) | | Conflict model | GO/AF (HLC), BB (server-order) | `ConflictStrategy::{HybridLogicalClock, ServerOrder}` | | Blob-bearing tables | AF only | `BlobPolicy` trait (below) | | Retention cap, cleanup window, batch, interval | all (different numbers) | `SyncConfig` | | Device-name source, status sink, error mapping | all (framework-specific) | `SyncObserver` trait | User scoping (a multi-tenant `WHERE user_id = ?` filter) is deliberately **not** an engine-level concern. The GoingsOn worked example (below) confirmed the single- user desktop apps never scope the *changelog* by user, the only place a user filter appears is inside GO's blob queries, which is policy-local to its `BlobPolicy`. A changelog-level user filter belongs to the future multi-tenant work (a third-party-phase prerequisite), not this layer. ## The declarative schema ```rust // Abridged from the shipped `store::schema` types. The real fields are private // (`pub(crate)`) and constructed through the `SyncSchema`/`SyncTable` builders // (`.table(...)`, `.pk(&[...])`, `.conflict(...)`); shown here public for shape. pub struct SyncSchema { /// Declaration order IS the FK order: parents first. The engine upserts in /// this order and deletes in the reverse. No hand-maintained reversed list. pub tables: Vec, /// Schema-wide conflict model (HLC or server-order). pub conflict: ConflictStrategy, } pub struct SyncTable { pub name: &'static str, /// The one column whitelist. Drives generated triggers, the snapshot /// projection, and apply-side binding — killing the triplication. pub columns: &'static [&'static str], /// PK column(s): one element = single key, more = composite (concatenated /// with ':' for the wire row-id). A slice, not a Single/Composite enum — /// simpler and matches the `.pk(&[...])` builder. Defaults to `["id"]`. pub pk: &'static [&'static str], pub mode: SyncMode, pub deletes: DeleteMode, pub row_id: RowIdScheme, /// Columns to leave untouched on upsert (secrets that stay per-device). pub preserve_local: &'static [&'static str], /// Values to inject only on first INSERT (satisfy NOT NULL on preserved cols). pub insert_defaults: &'static [(&'static str, &'static str)], /// This table carries FKs into a table that is not synced; relax FK /// enforcement while applying it. pub references_unsynced: bool, /// Optional SQL predicate that excludes rows from sync (AF config-key /// denylist). A SQL string, not a closure, because it also compiles into /// the generated trigger's WHEN clause — not just the apply-side guard. pub exclude_where: Option<&'static str>, } pub enum SyncMode { /// Upsert every whitelisted column (INSERT … ON CONFLICT DO UPDATE). Full, /// UPDATE only `set` columns where PK matches; never insert, never replace. /// BB feed_items: sync just is_read/is_starred, never the article body. PartialUpdate { set: &'static [&'static str] }, } pub enum DeleteMode { Hard, /// Remote deletes are dropped on the floor (BB feed_items — content /// re-fetches from source). Ignore, /// Apply as `UPDATE … SET = now()` instead of DELETE, so local /// ON DELETE CASCADE can't wipe organization (AF samples). Tombstone { column: &'static str }, } pub enum RowIdScheme { /// Wire row_id is the cleartext primary key (GO, BB). PrimaryKey, /// Wire row_id is a salted hash of the PK; the cleartext PK is carried /// inside the (encrypted) payload for DELETEs (AF content tables). Requires /// three engine duties: own a per-vault `row_id_salt` in sync_state (generate /// once, never sync), register the `hash_row_id` SQL function on the /// connection, and make the generated DELETE trigger emit the PK column(s) /// into `data` (so pull reconstructs the WHERE from the payload, never the /// opaque row_id). Assigned per-table: content-bearing keys are Hashed, /// opaque-integer / closed-config keys stay PrimaryKey. Hashed, } ``` Note the FK-order simplification: today every app keeps `UPSERT_ORDER` *and* a hand-written `DELETE_ORDER` that a test asserts is its exact reverse. Declaration order supplies both. ## Generated migrations (the triplication killer) ```rust impl SyncSchema { /// Emit the full sync DDL: sync_state, sync_changelog, the HLC ledger tables, /// and every table's INSERT/UPDATE/DELETE trigger — the applying_remote guard, /// the json_object projection over `columns`, the extra OLD≠NEW guard for /// PartialUpdate tables, and the row_id expression (cleartext or hashed) — all /// derived from the same declaration the apply side reads. pub fn migration_sql(&self) -> String; } ``` An app runs `migration_sql()` once as a normal migration. Because triggers, snapshot, and apply all read one `columns` list, the drift class is eliminated by construction and the round-trip test each app carries today is deleted. ## The engine and its database seam The one real portability constraint: GO and BB drive SQLite through an async `sqlx::SqlitePool`; audiofiles uses `rusqlite` behind `spawn_blocking` (its `Connection` is `!Send`). The engine must not marry either driver. Resolution: **`SyncStore` owns its own `rusqlite` connection(s) to the app's SQLite file** (given a path or a connection factory). Under WAL, which all three apps already use, the engine's short read/apply transactions coexist with the app's own pool on the same file. The app's domain code is untouched; the sync engine simply becomes another connection that reads `sync_changelog` and writes the domain tables under the `applying_remote` guard. This also matches audiofiles exactly and removes GO/BB's async-sync code entirely. Per-connection state the engine controls on its own connection: `PRAGMA foreign_keys` (toggled off only while applying a `references_unsynced` table, then restored, or the connection dropped rather than returned in an unknown state), and the transaction that raises/lowers `applying_remote` *inside* the same commit as the writes (so a crash rolls the flag back, never stranding it at `'1'` and silently swallowing later local edits). ```rust pub struct SyncStore { /* schema, client, config, db factory, blob policy, observer */ } impl SyncStore { pub fn builder(db: DbSource, client: SyncKitClient, schema: SyncSchema) -> SyncStoreBuilder; /// One full cycle: ensure device → push → pull → (blobs) → stamp last_sync. pub async fn sync_now(&self) -> Result; /// Own the whole background loop (tick + SSE + gates + backoff), reporting /// through the SyncObserver. Returns a handle to stop it. pub fn spawn_scheduler(&self, observer: Arc) -> SchedulerHandle; pub async fn pending_changes(&self) -> Result; pub async fn disconnect(&self) -> Result<()>; } pub struct SyncOutcome { pub pushed: u64, pub pulled: u64, pub changed_tables: HashSet } ``` ## Conflict strategy GO and AF run the full HLC pipeline (`detect_conflicts` → committed-HLC gating → `resolve_lww`); BB currently sends a nil HLC and leans on server ordering. Rather than force a migration, expose it: ```rust pub enum ConflictStrategy { /// Stamp a real HLC per edit, gate clean changes against committed clocks, /// resolve true conflicts with resolve_lww. Deterministic multi-device /// convergence. The default and recommended setting. HybridLogicalClock, /// Trust server seq order; last delivered wins. Simplest; adequate when /// concurrent edits to the same row are rare/uninteresting. ServerOrder, } ``` Default `HybridLogicalClock`; BB can adopt it (it is the roadmap intent anyway) or stay on `ServerOrder` during migration. ## Blob policy Only audiofiles syncs blobs, but the shape is general: some rows own a content-addressed binary, gated by an enable flag, with a local/cloud presence bit and an in-place escape hatch. Expressed as a trait the engine drives *after* the metadata pull, non-fatally: ```rust pub trait BlobPolicy: Send + Sync { /// Rows whose bytes should be uploaded (AF: samples in a sync_files VFS, /// not cloud_only, with a local file present). The engine skips any whose /// local_path() is absent, so this may return the full candidate set. fn pending_uploads(&self, conn: &Connection) -> Result>; /// Rows whose bytes are missing locally and wanted (AF: sync_files VFS, /// source_path IS NULL). The engine skips any whose local_path() exists. fn pending_downloads(&self, conn: &Connection) -> Result>; fn local_path(&self, blob: &BlobRef) -> PathBuf; // Presence bookkeeping — default no-ops. An app that tracks local-vs-cloud // presence purely by disk existence (GoingsOn) implements none of these; // an app with an explicit presence flag (AF cloud_only) overrides them. /// Reconcile presence flags before the passes (AF: mark_cloud_only_samples). fn reconcile(&self, _conn: &Connection) -> Result<()> { Ok(()) } /// Flip bookkeeping after upload, echo-suppressed (AF: cloud_only). fn on_uploaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> { Ok(()) } /// Flip bookkeeping after download, echo-suppressed (AF: cloud_only). fn on_downloaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> { Ok(()) } } pub struct BlobRef { pub hash: String, pub ext: String, pub size: u64 } ``` The engine owns the invariant parts: the skip-if-`local_path`-absent → `upload_file` (which streams and dedups server-side) → `blob_confirm` dance, the `blob_download_url` → skip-if-`local_path`-present → `blob_download` → **SHA-256 re-verify** → atomic temp+rename write, and the single-item `download_one(hash)` path for a GUI "download now" action. The policy trait only answers "which blobs," "where on disk," and (optionally) "how to reflect presence." GoingsOn's attachment policy needs only the first three methods; audiofiles overrides all six. ## Status reporting Status must be framework-agnostic: GO/BB emit Tauri events, AF mutates an `Arc>`. A trait bridges both: ```rust pub trait SyncObserver: Send + Sync { fn on_status(&self, status: SyncStatus); // Syncing/Idle/Error/SubscriptionRequired/LoggedOut fn on_changes_applied(&self, tables: &HashSet); // selective cache invalidation fn on_subscription_required(&self); } ``` ## What each app sheds | App | Today | After `SyncStore` | |---|---|---| | **GoingsOn** | ~1,400 non-test LOC across push/pull/apply/hlc/state/blob_sync/scheduler + trigger DDL + drift test | A 20-table `SyncSchema`, `email_accounts` `preserve_local`+`insert_defaults`, `references_unsynced` on `tasks`+`attachments`, a thin attachment `BlobPolicy`, a Tauri `SyncObserver` (see worked example below) | | **audiofiles** | ~1,400 non-test LOC + the whole blob subsystem + tombstone/hash-id/config-exclusion logic | A 16-table `SyncSchema` (per-table `Hashed` row-ids, `Tombstone` on samples, `exclude_where` on user_config, composite/all-PK tables), the full 5-method `BlobPolicy` impl (kept, genuine policy), an `Arc` `SyncObserver` (see worked example below) | | **Balanced Breakfast** | ~700 non-test LOC + triplicated columns | A 7-table `SyncSchema` with `feed_items = PartialUpdate{set:[is_read,is_starred]} + DeleteMode::Ignore`, `MAX_CHANGELOG_ENTRIES` in `SyncConfig`, a Tauri `SyncObserver`. Adopts `HybridLogicalClock` | ## Design decisions Decided (2026-07-12): 1. **DB ownership: the engine owns a private `rusqlite` connection to the app's WAL file.** Not a `SyncDb` trait over the app's `sqlx` pool. This is far simpler, matches audiofiles exactly, and lets GoingsOn and Balanced Breakfast delete their async sync code wholesale. The engine's short read/apply transactions coexist with the app's own pool under WAL. Consequence: the app passes a path (or a connection factory for custom pragmas/shared-cache), and the engine, not the app, holds the FK-pragma and `applying_remote` state on its own connection. 2. **Triggers are generated from the manifest**, not hand-written SQL. One `columns` list per table produces the trigger DDL, the snapshot projection, and the apply binding, so the column-drift bug class is closed by construction and the per-app round-trip drift test is deleted. Accepted cost: the engine emits SQL the app reviews once at migration time. Remaining to confirm during build (not blocking): 3. **Null-tolerance merge policy** (omit NOT-NULL-null columns, keep nullable-null) reads the live schema, so it defaults into the engine; confirm no app needs a different per-table rule. 4. **`extra`/forward-compat fields**, the apply path must thread `ChangeEntry.extra` through unchanged so a newer client's fields survive an older client's round-trip; confirm nothing drops them. ## Worked example: GoingsOn GoingsOn is the richest consumer (20 tables, credential exclusion, cross-table FKs into unsynced data, blobs, HLC), so it is the design's stress test. Its entire sync engine, `push.rs`, `pull.rs`, `apply.rs`, `hlc.rs`, `state.rs`, `blob_sync.rs`, most of `mod.rs`, the hand-written trigger migrations, and the drift test, reduces to the manifest and one thin trait impl below. Column lists are the *actual* `SYNCED_COLUMNS` from `apply.rs`. ```rust use synckit_client::store::{SyncSchema, SyncTable, ConflictStrategy}; // Common case: single `id` PK, cleartext wire row-id, Full upsert (ON CONFLICT // DO UPDATE — never INSERT OR REPLACE, so ON DELETE CASCADE can't wipe children), // Hard delete, HLC conflict resolution. 17 of GO's 20 tables are one line each. fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable { SyncTable::full(name, cols) } pub fn goingson_schema() -> SyncSchema { // Declaration order = FK order (parents first). Deletes run the exact reverse // automatically — no hand-maintained DELETE_ORDER, no test asserting it. SyncSchema::new(vec![ t("projects", &["id","name","description","project_type","status","created_at","user_id"]), t("contacts", &["id","user_id","display_name","nickname","company","title","notes","tags", "birthday","timezone","external_source","external_id","created_at","updated_at"]), // email_accounts: sync config, NEVER credentials. GO's bespoke ~40-line // apply_email_account_upsert() dissolves into these two declarative fields: // preserve_local → the 4 secret columns are untouched on conflict-update // insert_defaults → new rows get password='' to satisfy its NOT NULL SyncTable::full("email_accounts", &["id","user_id","account_name","email_address","imap_server","imap_port", "smtp_server","smtp_port","username","use_tls","created_at","archive_folder_name", "auth_type","jmap_session_url","jmap_account_id","sync_interval_minutes","email_signature"]) .preserve_local(&["password","oauth2_access_token","oauth2_refresh_token","oauth2_token_expires_at"]) .insert_defaults(&[("password", "")]), t("sync_accounts", &["id","user_id","provider","account_name","email","sync_calendars", "sync_contacts","calendar_ids","sync_interval_minutes","enabled","created_at"]), t("milestones", &["id","user_id","project_id","name","description","position","target_date", "status","created_at"]), // tasks & attachments both carry source_email_id — a FK into the emails // table, which is NOT synced (emails stay per-device). Relax FK enforcement // while applying them. (In GO today this is a blanket PRAGMA foreign_keys=OFF // over the whole apply; the engine does the same when any batched table is // references_unsynced.) SyncTable::full("tasks", &["id","project_id","description","status","priority","due","tags","urgency","recurrence", "recurrence_rule","created_at","user_id","recurrence_parent_id","source_email_id", "snoozed_until","waiting_for_response","waiting_since","expected_response_date", "scheduled_start","scheduled_duration","is_focus","focus_set_at","contact_id", "milestone_id","completed_at","estimated_minutes","actual_minutes"]) .references_unsynced(), t("time_sessions", &["id","task_id","user_id","started_at","ended_at","duration_minutes","created_at"]), SyncTable::full("attachments", &["id","user_id","task_id","project_id","filename","file_size","mime_type","blob_hash", "source_email_id","created_at"]) .references_unsynced(), t("events", &["id","project_id","title","description","start_time","end_time","location","user_id", "linked_task_id","recurrence","recurrence_parent_id","recurrence_rule","contact_id", "block_type","external_source","external_id","is_read_only","snoozed_until", "reminder_offsets_seconds"]), t("annotations", &["id","task_id","timestamp","note"]), t("subtasks", &["id","task_id","text","is_completed","position","created_at","linked_task_id"]), t("contact_emails", &["id","contact_id","address","label","is_primary"]), t("contact_phones", &["id","contact_id","number","label","is_primary"]), t("contact_social_handles", &["id","contact_id","platform","handle","url"]), t("contact_custom_fields", &["id","contact_id","label","value","url"]), t("daily_notes", &["id","user_id","note_date","went_well","could_improve","is_reviewed", "reviewed_at","created_at","updated_at"]), t("saved_views", &["id","user_id","name","view_type","filters","sort_by","sort_order", "is_pinned","position","created_at","updated_at"]), t("weekly_reviews", &["id","user_id","week_start_date","completed_at","notes","vacation_days"]), t("monthly_goals", &["id","user_id","month","text","status","position","created_at","updated_at"]), t("monthly_reflections", &["id","user_id","month","highlight_text","change_text","completed_at"]), ]) .conflict_strategy(ConflictStrategy::HybridLogicalClock) // GO's current model; also the default } ``` The blob policy is thin, GO tracks local-vs-cloud presence purely by disk existence (no `cloud_only` flag), so it implements only the three required methods and inherits the no-op presence hooks: ```rust struct AttachmentBlobs { user_id: String, data_dir: PathBuf } // user_id = DESKTOP_USER_ID impl BlobPolicy for AttachmentBlobs { fn pending_uploads(&self, conn: &Connection) -> Result> { // SELECT DISTINCT blob_hash, file_size FROM attachments WHERE user_id = ? // (engine skips any whose local_path is absent) } fn pending_downloads(&self, conn: &Connection) -> Result> { // SELECT DISTINCT blob_hash FROM attachments WHERE user_id = ? // (engine skips any whose local_path already exists; then SHA-256-verifies) } fn local_path(&self, blob: &BlobRef) -> PathBuf { blob_path(&self.data_dir, &blob.hash) } // reconcile / on_uploaded / on_downloaded: inherited no-ops. } ``` ### What GO proved about the design - **Confirmed: credential exclusion collapses.** The single most bespoke piece of GO's apply layer (`apply_email_account_upsert`, its own INSERT-with-`password=''` and 16-column ON CONFLICT) is fully expressed by `preserve_local` + `insert_defaults`. No per-table override function survives. This was the strongest signal the enum set is at the right altitude. - **Confirmed: 17 of 20 tables are one line.** Only `email_accounts`, `tasks`, and `attachments` need a builder call; the rest are `t(name, cols)`. - **Refinement 1: `user_scope` is not an engine concept.** GO never filters the *changelog* by user; the only `WHERE user_id = ?` is inside its blob queries. So user scoping is policy-local (it lives in `AttachmentBlobs`), and the engine- level `user_scope` field was removed. A changelog-level tenant filter is deferred to the future multi-tenant work. - **Refinement 2: blob presence hooks must default to no-ops.** GO has no `cloud_only` equivalent, so `reconcile`/`on_uploaded`/`on_downloaded` gained default empty bodies; only AF overrides them. - **Corrected: `references_unsynced` covers `attachments` too**, not just `tasks` (both carry `source_email_id`). The engine's rule "disable FK for the apply txn if any batched table is `references_unsynced`" matches GO's existing blanket toggle. ## Worked example: audiofiles audiofiles is the design's hardest test: it exercises everything GoingsOn does not , salted-hash wire row-ids, tombstone deletes, a SQL exclusion predicate, composite and all-PK tables, and the full six-touch `BlobPolicy`. Column lists, PKs, and orders are the actual `table_columns`/`pk_columns` from `service/mod.rs`. ```rust use synckit_client::store::{SyncSchema, SyncTable}; // Cleartext single-`id` PK, Full upsert, Hard delete (opaque-integer tables that // carry no user content in their key — never row-id-hashed per M018). fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable { SyncTable::full(name, cols) } pub fn audiofiles_schema() -> SyncSchema { SyncSchema::new(vec![ t("vfs", &["id","name","created_at","modified_at","sync_files"]), // samples: stacks THREE modifiers — its PK is a content hash, so the wire // row-id is salted-hashed; and a remote delete must be a tombstone, never a // hard DELETE (vfs_nodes/tags/collection_members carry ON DELETE CASCADE // that applying_remote can't suppress). SyncTable::full("samples", &["hash","original_name","file_extension","file_size","import_date", "last_modified","cloud_only","duration"]) .pk(&["hash"]) .hashed() .tombstone("deleted_at"), SyncTable::full("sample_features", &["hash","feat_version","vector","computed_at"]) .pk(&["hash"]).hashed(), t("collections", &["id","name","description","created_at","filter_json"]), t("vfs_nodes", &["id","vfs_id","parent_id","name","node_type","sample_hash","created_at"]), SyncTable::full("audio_analysis", &["hash","bpm","musical_key","duration","sample_rate","channels","peak_db","rms_db", "is_loop","spectral_centroid","onset_strength","analyzed_at","lufs","spectral_flatness", "spectral_rolloff","zero_crossing_rate","classification","spectral_bandwidth", "centroid_variance","crest_factor","attack_time","classification_confidence"]) .pk(&["hash"]).hashed(), // tags: composite PK AND all columns are PK → engine emits INSERT OR IGNORE; // both key columns are user content → hashed. SyncTable::full("tags", &["sample_hash","tag"]).pk(&["sample_hash","tag"]).hashed(), SyncTable::full("tag_provenance", &["sample_hash","tag","source","rule_id"]) .pk(&["sample_hash","tag"]).hashed(), t("tag_rules", &["id","name","enabled","priority","match_mode","conditions","actions","created_at"]), SyncTable::full("tag_policy", &["tag","review_threshold","auto_threshold"]) .pk(&["tag"]).hashed(), t("classifier_layers", &["id","name","kind","weight","enabled","source","imported_at"]), t("classifier_exemplars", &["id","layer_id","feat_version","vector","tags"]), SyncTable::full("classifier_layer_rules", &["layer_id","rule_id"]).pk(&["layer_id","rule_id"]), SyncTable::full("collection_members", &["collection_id","sample_hash","added_at"]) .pk(&["collection_id","sample_hash"]).hashed(), // user_config: a SQL exclusion predicate applied symmetrically — it compiles // into the generated trigger WHEN (export) and the apply guard (import), so // sync-internal keys and the loose_files toggle never cross in either direction. SyncTable::full("user_config", &["key","value"]) .pk(&["key"]) // {row} → NEW/OLD per generated trigger (INSERT/UPDATE use NEW, DELETE uses OLD). .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\' AND {row}.key <> 'loose_files'"), t("edit_history", &["id","source_hash","result_hash","operation","params_json","created_at"]), ]) // ConflictStrategy::HybridLogicalClock is the default; AF uses it. } ``` The blob policy is the full implementation, AF tracks presence with an explicit `cloud_only` flag, so it overrides the presence hooks GO left as no-ops: ```rust struct SampleBlobs { content_dir: PathBuf } impl BlobPolicy for SampleBlobs { fn pending_uploads(&self, conn: &Connection) -> Result> { // samples in a sync_files VFS, blob present locally (cloud_only = 0): // SELECT DISTINCT s.hash, s.file_extension, s.file_size FROM samples s // JOIN vfs_nodes vn ON vn.sample_hash=s.hash JOIN vfs v ON v.id=vn.vfs_id // WHERE v.sync_files = 1 AND s.cloud_only = 0 } fn pending_downloads(&self, conn: &Connection) -> Result> { // samples in a sync_files VFS not referenced in place: // … WHERE v.sync_files = 1 AND s.source_path IS NULL (engine skips present files) } fn local_path(&self, b: &BlobRef) -> PathBuf { self.content_dir.join(format!("{}.{}", b.hash, b.ext)) } // Overridden — the cloud_only presence flag (all echo-suppressed): fn reconcile(&self, conn: &Connection) -> Result<()> { // mark_cloud_only_samples: for cloud_only=0 AND source_path IS NULL samples // whose file is missing on disk, set cloud_only = 1. } fn on_downloaded(&self, conn: &Connection, b: &BlobRef) -> Result<()> { // clear cloud_only for b.hash after a verified fetch lands the file. } // on_uploaded: inherited no-op — AF's cloud_only is managed by reconcile+download, // not by upload (an uploaded sample was already local, i.e. cloud_only=0). } ``` ### What AF proved about the design - **Confirmed: the hard cases express.** `DeleteMode::Tombstone{column}` captures the samples CASCADE-safety rule; `PrimaryKey::Composite` + the engine's all-columns-are-PK → `INSERT OR IGNORE` detection handles `tags`/ `classifier_layer_rules`; and `samples` stacking `hashed()` + `tombstone()` + a non-`id` PK in one declaration validates that the modifiers compose. - **Confirmed: the six-method `BlobPolicy` fits.** AF's `sync_files`/`cloud_only`/ `source_path` machinery maps onto the two queries + `local_path` + `reconcile` + `on_downloaded`, with `on_uploaded` correctly a no-op. GO uses 3 methods, AF uses 5, the trait spans both without a special case. - **Refinement (important), `exclude` must be SQL, not a Rust closure.** AF enforces the config denylist *symmetrically*: on import (apply guard) AND on export (the changelog trigger's WHEN clause), the sensitive key must never enter the changelog in the first place (fuzz-2026-07-06 #2). A `fn(&Row)->bool` cannot compile into a generated SQL trigger, so the hook became `exclude_where: &str`, a SQL predicate the engine drops into both the trigger and the apply-time check. This is the one place the GO-derived API was actually wrong. - **Refinement: `RowIdScheme::Hashed` is per-table and adds engine duties.** Only content-bearing keys are hashed (samples/analysis/features/tags/tag_policy/ collection_members); opaque-integer and config tables stay `PrimaryKey` (M018's own split). Hashed obligates the engine to own the per-vault `row_id_salt` (generate once in sync_state, never sync), register `hash_row_id`, and emit the canonical PK into `data` on generated DELETE triggers so pull reconstructs the WHERE from the payload. Documented on the enum. - **Boundary noted: tombstone reads/sweep stay app-side.** `Tombstone{column}` owns only the apply-time write (`SET deleted_at = COALESCE(deleted_at, now())`). The `deleted_at IS NULL` read filter on domain queries and the 30-day hard-delete sweep are AF domain code, not the sync engine, the engine just stops the remote DELETE from cascading.