Sync Architecture
Overview
File Sync removed — cloud sync only
@takazudo/sync-client and the manifest-based File Sync mode have been removed. The package still exists as an orphaned artifact but has no consumers; SyncSettings in app-defaults has no syncMode field. Only Cloud Sync (@takazudo/cloud-sync) is active. The sections below that reference @takazudo/sync-client are retained as historical record.
zudo-text cloud sync keeps workspace files in sync across devices using real-time push/pull with E2E encryption. Implemented in @takazudo/cloud-sync and @takazudo/cloud-crypto. Sync is opt-in and requires a Pro subscription (30-day free trial available).
ローカル FS ミラーリングは転換前のモデル
以下のうち、ローカルファイルとワークスペースの双方を真実として扱う記述は歴史的記録です — 具体的には Architecture Diagram の "Local Files" ボックス、"Both devices maintain local files and sync through the server."、Data Flow / Desktop push-on-save のローカルワークスペース前提、Sync Outbox の旧スコープ {workspaceRoot, workspaceId}。zudo-text は cloud-primary へ転換済みで、ワークスペースが唯一の保管先です。ローカルワークスペースディレクトリは廃止され、outbox は workspace identity 単独でスコープされます。確定した契約は Cloud-Primary Storage。転換前の状態はタグ pre-cloud-pivot に残っています。
暗号化、鍵導出、WebSocket、conflict resolution、プラットフォーム別の key-at-rest に関する記述は転換後も有効です。
Architecture Diagram
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Tauri App │──push───▶│ Sync Server │◀──push───│ Tauri App │
│ (device A) │◀──pull───│ (API / WS) │───pull──▶│ (device B) │
└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
Local Files Cloud Storage Local Files
(.md files) (R2 / S3) (.md files)Both devices maintain local files and sync through the server. The server stores only encrypted blobs — it never sees plaintext content.
Packages
@takazudo/sync-client (removed legacy)
Removed
@takazudo/sync-client was removed with the file-sync subsystem. Its SyncClient, resolveConflict(), and hashContent() exports are no longer consumed by any active code path. Cloud sync lives in @takazudo/cloud-sync.
Manifest-based file sync. The client hashes all local files, sends the manifest to the server, receives a diff of what to upload and download, and transfers only the changed files.
@takazudo/cloud-sync
Real-time cloud sync with WebSocket support and offline resilience.
| Export | Description |
|---|---|
CloudSyncClient | HTTP client for push/pull sync, workspace management, file operations, device registration |
WsManager | WebSocket connection manager with auto-reconnect and exponential backoff |
ChangeTracker | Tracks pending local changes (path, action, content hash) |
SyncOutbox | Durable, workspace-scoped, encrypted-at-rest upload outbox with per-path coalescing (epic #4176, #4180). Replaced the never-wired OfflineQueue below. |
OfflineQueue removed — replaced by SyncOutbox
OfflineQueue was never actually wired into a live upload path and has been removed (#4180). Local upserts and delete tombstones are now enqueued into the durable SyncOutbox (packages/) BEFORE any network attempt — persisted encrypted-at-rest, coalesced per path (a burst of autosaves produces one upload of the latest content), and retried with backoff across restarts. This also means desktop pushes changes per save, not just on a pull/reconnect cycle — see "Desktop push-on-save" below, which corrects the pull-only description this document previously carried.
@takazudo/cloud-crypto
End-to-end encryption layer. The server never sees plaintext — all encryption and decryption happens on the client.
| Export | Description |
|---|---|
deriveKeys() | PBKDF2 key derivation — derives 3 sub-keys from a password (encryption, HMAC, path) |
deriveVerificationHash() | Generates a verification hash using a different salt prefix to avoid leaking encryption keys |
encryptContent() | AES-256-GCM encryption with random 12-byte IV |
decryptContent() | AES-256-GCM decryption |
computeHmac() | HMAC-SHA256 for content integrity |
encryptPath() | Deterministic path encryption (HMAC-derived IV + AES-GCM) |
decryptPath() | Path decryption |
serializeBlob() / deserializeBlob() | Binary blob format for encrypted payloads |
encryptAssetName() / decryptAssetName() | Deterministic encryption for cloud asset lookup tokens |
serializeAssetBlob() / deserializeAssetBlob() | Randomized encryption plus HMAC authentication for binary assets |
@takazudo/backend-bridge
The abstraction layer between the frontend and backend. All sync and auth operations go through the bridge's adapter pattern:
TauriAdapter — real Tauri IPC calls (production)
MockAdapter — in-memory simulation (Storybook, tests)
RestAdapter — HTTP/SSE (development with REST backend)
Data Flow
File Sync (manifest-based) — removed
Removed
The File Sync flow described below used the removed @takazudo/sync-client subsystem and is no longer active.
collectWorkspaceFiles() → hash each file → POST manifest → upload/download diff → writeDownloadedFiles()
Cloud Sync (real-time, encrypted)
Upserts (content saves) and deletes take different paths from the moment they're recorded:
Upsert (save/create/move/archive/Tidy-Up/pin-write/popout — a content change):
1. recordLocalUpsert(path, content) — echo-loop guard first: ChangeTracker.track()
suppresses this call entirely if it is really the local write-back of a
change just pulled from the server (nothing queued, no upload)
2. SyncOutbox.enqueue(path, content) — persists the entry SEALED (encrypted
at rest with the workspace's key material) BEFORE any network attempt or wire
encryption; a re-enqueue of the same path replaces the pending entry
3. After a 10 s quiet window (with a 60 s maximum deferral during continuous
activity), the outbox's uploader encrypts the content
for the WIRE (AES-256-GCM) and the path (deterministic AES-GCM), then
PUTs the blob — this is where wire encryption actually happens, not at
enqueue time
4. Server assigns a version number, stores the encrypted blob, auto-logs the
upsert change-row
5. Server notifies other devices via WebSocket ("changes" message)
6. Other devices pull, decrypt with local keys
Delete:
1. recordLocalDeleteDurable(path) — same echo-loop guard, then enqueues a
delete tombstone into the SyncOutbox (Durable Tombstones, epic #4319).
enqueueDelete supersedes any queued-but-unflushed upsert for the same
path, so a pending upload can never resurrect a file just deleted
2. The tombstone is sealed and persisted to localStorage BEFORE any network
attempt — an offline delete survives a restart and replays on the next
launch. The ChangeTracker entry is acknowledged synchronously at enqueue
(outbox-owned from that instant), so a concurrent /sync/push drain can
never double-submit the same delete
3. The outbox's delete upload calls DELETE /files/:encPath (a 404 counts as
delivered); the server logs the tombstone change-row. Failures stay
queued in the outbox and retry with its backoff — deletes no longer take
any path through POST /sync/push A row the host adopted from a closed popout's abandoned outbox queue (see cloud-primary-storage.mdx "popout 専用キューの引き取り") uploads under the POPOUT's windowed device id on both paths, upsert and delete alike — stamping the host's own id would make the host's pull-side self-authorship filter (collapseRawRows) drop its own pulled row (epic #4319 for deletes, extended to upserts by #4404). Because that makes the row foreign to the host, collapseRawRows also drops such a replayed row while the host's own newer mutation for the same path is still queued in the outbox, so the replay cannot overwrite the newer local content (adoptedReplayGuards).
Desktop push-on-save
Every app-originated mutation — writes, creates, deletes, archive-moves, Tidy Up, pin writes, and popout — pushes as soon as it happens (upserts and deletes both via the SyncOutbox's debounced, durable queue — see above), epic #4176 / #4180 / #4183 / #4319. This corrects an earlier version of this document, which described the desktop (Tauri) adapter as pull-only. Local-only External File Editor operations are outside the cloud workspace and do not enter this sync pipeline.
Sync Outbox
The SyncOutbox (upserts and delete tombstones) ensures no local content change is lost, whether the device is offline or the app simply hasn't gotten around to uploading it yet:
enqueue(path, content, generation)persists an upsert SEALED (encrypted with the workspace's key material, distinct from the later wire encryption) before any network attempt;enqueueDelete(path, generation)applies the same durability to a delete tombstone. Both are keyed by plaintext path in memory, so the latest action for a path wins and restarts the debounce.A 10 s trailing quiet window coalesces autosaves for the same path into one upload of the latest content. Continuous activity cannot defer the next upload beyond 60 s from the start of the queued batch (the configurable delay retains a ≥ 1 s safety floor).
A failed upload stays queued and is retried with exponential backoff; a process restart reloads persisted entries and resumes draining them once the workspace is re-armed.
awaitFlush(workspaceId)resolves only once the outbox for that workspace is empty — used before state-capturing operations (Note History / Checkpoints reads, restores) so they never observe a stale head with edits still sitting in the debounce window.Storage is scoped by workspace identity and, like the rest of the key-at-rest posture below, sealed rather than stored in the clear — see "Key-at-Rest" for why the underlying
localStoragemedium itself is still plaintext-on-disk.
WebSocket Real-Time Sync
The WsManager maintains a persistent WebSocket connection for instant cross-device notifications.
Connection Lifecycle
connect() → WebSocket open → send "hello" { cursor, deviceId }
→ receive "welcome" { cursor, deviceCount }
→ start ping interval (30s)
→ on "changes" → update cursor, notify callback
→ on close → exponential backoff reconnect (1s → 30s max)
disconnect() → close socket, cancel timersMessage Types
Client to Server:
| Type | Fields | Description |
|---|---|---|
hello | cursor, deviceId | Initial handshake after connection |
ping | — | Keep-alive (every 30 seconds) |
Server to Client:
| Type | Fields | Description |
|---|---|---|
welcome | cursor, deviceCount | Connection acknowledged |
changes | changes[], cursor | New changes from other devices |
pong | — | Keep-alive response |
error | code, message | Server-side error |
device_connected | deviceId, deviceName | Another device came online |
device_disconnected | deviceId | Another device went offline |
Conflict Resolution
The two sync packages use different conflict type enums. @takazudo/cloud-sync uses the types below (for the real-time encrypted sync API). @takazudo/sync-client uses both-modified, delete-modify, and modify-delete (for the legacy manifest-based sync).
Conflict Types (Cloud Sync)
Conflicts are detected when both local and remote changes target the same file:
| Type | Scenario |
|---|---|
concurrent-edit | File modified on both local and remote since last sync |
edit-delete | File modified locally, deleted remotely |
delete-edit | File deleted locally, modified remotely |
Resolution Strategies
Manual (default):
Users choose per-conflict via the SyncConflictDialog:
| Strategy | Result |
|---|---|
use-local | Keep the local version |
use-server / accept-incoming | Replace with the server version |
save-both | Create both <name>.local.<ext> and <name>.remote.<ext> |
three-way-merge | Attempt programmatic merge (falls back to use-local) |
Automatic (autoResolveConflicts):
For accept-incoming strategy:
All conflicts resolve to
use-server
For auto-merge strategy:
If hashes match → skip (already identical)
edit-delete→ keep local (preserve user's edits)delete-edit→ use server (restore remote edits)concurrent-edit→ use server (safe default)
A second, separate conflict surface on the pull side (epic #4808). The table above governs SyncConflictDialog / autoResolveConflicts, whose only production feed is push conflicts on tombstones — queue-shaped, not snapshot-shaped. Reconnecting after an offline session with local edits queued has its own deterministic rules (applyRemoteRows, "local wins on an un-synced edit, server wins on a foreign post-cursor upsert against a queued delete") — see Encrypted Local Workspace Mirror §8. #4814 verified the two surfaces make no conflicting assumptions about each other.
Security
Key Derivation
The cloud-crypto package derives three independent 32-byte sub-keys from a single password using PBKDF2 (600,000 iterations, SHA-256):
Password + Salt (32 bytes)
↓ PBKDF2 (600k iterations)
96-byte master key
├── bytes 0-31: encryptionKey (AES-256-GCM, file content)
├── bytes 32-63: hmacKey (HMAC-SHA256, content integrity)
└── bytes 64-95: pathKey (AES-256-GCM, path encryption)Password Verification
A separate verification hash is derived using a different salt prefix ("verify:" + salt) to prevent key leakage:
1. bits = PBKDF2(password, "verify:" + salt, 600k iterations, SHA-256) → 32 bytes
2. hash = SHA-256(bits) → 32 bytes
3. verificationHash = hexEncode(hash)The two-step process (PBKDF2 then SHA-256) is intentional — it prevents the PBKDF2 output from being used directly as a verification oracle. This hash is stored on the server to verify the password without exposing the encryption keys.
File Content Encryption
Algorithm: AES-256-GCM
IV: Random 12 bytes per encryption (non-deterministic)
Format: Serialized as a versioned binary blob (
serializeBlob/deserializeBlob)
Path Encryption
Algorithm: AES-256-GCM with deterministic IV
IV derivation: HMAC-SHA256(hmacKey, plaintext_path) truncated to 12 bytes
Deterministic: Same path always produces the same encrypted output, enabling server-side matching
Encoding: base64url(iv + ciphertext)
Asset Encryption
Assets are a parallel encrypted surface, not workspace files. The renderer-facing bridge.assets.* contract remains plaintext: callers supply ordinary filenames and base64 bytes, and receive ordinary filenames and base64 bytes. When a cloud workspace is armed, @takazudo/backend-bridge uses the active workspace keys to transform that contract at the network boundary:
Filename: deterministic AES-256-GCM through the path cipher, using an HMAC-derived 96-bit IV and base64url encoding. This gives the server a stable opaque lookup token without exposing the plaintext filename.
Contents: randomized AES-256-GCM in the canonical serialized blob envelope, authenticated with HMAC-SHA256. Authentication is verified before decryption.
Metadata: the server stores the encrypted envelope size and upload time. File kind and MIME type are derived client-side from the decrypted extension; neither plaintext filename nor MIME is sent to the server.
Object location: D1 maps
(workspace_id, encrypted_filename)to a random, per-upload R2 key underassets-e2ee/<workspaceId>/.
The deterministic name is an accepted information leak, not semantic security for traffic patterns. It reveals equality of filenames within a workspace, and token/ciphertext length tracks plaintext filename UTF-8 byte length. Encrypted object size reveals plaintext byte size plus a fixed envelope overhead. The server also observes upload timing and list/read/delete access patterns. Finally, the deterministic 96-bit IV inherits a theoretical nonce-collision caveat at extreme namespace sizes; bounded per-workspace filename counts make it negligible in practice, but do not make it mathematically impossible.
Assets deliberately remain outside workspaceFiles, SyncOutbox, and the local workspace mirror: the outbox has no binary arm, putting 25 MiB blobs in IndexedDB would inflate the mirror, and workspace-file hydration would make every device pull every asset. Base64 transport is retained solely for the unchanged bridge contract; replacing its roughly 33% expansion with a binary/streaming transport is a follow-up.
End-to-End Guarantees
Content-bearing server fields are encrypted blobs, encrypted paths, and opaque encrypted asset names; observable metadata is listed above
Key material never leaves the client device
HMAC authentication ensures data integrity
Password verification uses a separate derivation to avoid leaking encryption keys
Known, deliberate exceptions. Two surfaces trade a bounded, explicit slice of the zero-knowledge guarantee above for programmatic or AI access to plaintext — both are opt-in per use, never a standing property of an account or a token:
The Automation API's key sessions. Before an automation client (a script, the MCP integration, or an AI agent tool call) can read or write plaintext, it must explicitly post the workspace's three derived decryption keys to open a session. The server holds them in Durable Object memory only, for at most one hour, never on disk. See Automation API → Key Sessions for the wire mechanics and Personal Access Tokens → The key-session exception for the token-side tradeoff.
Agent conversation retention. The Flue agent platform that backs the AI assistant and MCP's
ask_zudo_agentkeeps a durable, append-only conversation record per thread. A tool result — the content of a note the assistant read, or the diff it proposed — becomes part of that record the same way any other turn does, so conversation history can retain plaintext excerpts of workspace notes. There is no separate at-rest encryption for conversation content the way there is for workspace files. See Flue Agent Platform → Retention and deletion for exactly what a conversation delete does and does not erase.
Key-at-Rest (iOS / web / desktop)
The derived workspace master key is persisted by tauri- so the workspace survives a cold start without re-prompting for the passphrase. The backend is platform-specific:
Desktop Tauri on macOS: one generic-password item per workspace in the user's login Keychain. The Keychain service follows the app's bundle identity, so ROOT and generated LEAF apps do not share each other's items. A native failure or denied access never falls back to plaintext
localStorage.iOS and web: the existing per-workspace
localStoragestore remains. In a zero-knowledge, end-to-end-encrypted app the master key is the whole secret, andlocalStorageis plaintext at rest. This remains an accepted pre-release tradeoff for these platforms.
Residual risk
On iOS and web,
localStorageis plaintext-on-disk and readable by JavaScript running in that realm, so an XSS foothold would leak the master key outright.iOS mitigates the disk side only: WKWebView storage is sandboxed per-app and covered by the device data-protection class (encrypted at rest while the device is locked). It does not mitigate the in-webview XSS path.
Desktop Keychain access still occurs through the renderer/backend IPC seam. A renderer compromise while the app is authorized can therefore request the key; Keychain hardens storage at rest, not a live compromised process.
Why localStorage remains accepted on iOS and web for now
On iOS and web, the local store preserves the no-reprompt cold-start UX until a native secure-store implementation is available.
The user can clear the persisted key at any time via the iOS cloud-config surface (sign out / lock) or the desktop Settings → Sync sign-out.
The remaining native fix (iOS, deferred)
A native iOS Keychain / Secure-Enclave Tauri plugin keeps the key out of plaintext-at-rest while preserving prompt-free cold start. That work is tracked by #4458 and is blocked on the paid Apple Developer membership. Desktop macOS does not share that blocker; its login-Keychain backend is implemented.
The storage seam
workspace-key-store.ts exposes a small asynchronous KeyStoreBackend interface (write / read / remove / removeAll). Desktop macOS selects the backend-bridge Keychain adapter; iOS and web select the localStorage adapter; tests can inject their own backend. Its public operations (persistWorkspaceKey / loadWorkspaceKey / clearWorkspaceKey / clearAllWorkspaceKeys / hasPersistedWorkspaceKey / rearmWorkspaceKeyFromStore) are async because native Keychain access crosses Tauri IPC. Operations are serialized in call order and there is deliberately no in-memory cache inside the key store, so a read in one window cannot be served from another realm's stale cached payload. Original finding: #2335.
The store is keyed per workspace id (the Keychain item account on desktop; the zudotext:sync:workspace-key:<workspaceId> key on iOS and web), not a single global slot. The workspace switcher lets one running instance bind different workspaces in-session, so clearWorkspaceKey(workspaceId) (lock ONE workspace) and clearAllWorkspaceKeys() (sign out — erase every persisted workspace key on this device) are separate operations rather than one undifferentiated "drop the key" (#4609).