zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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.

ExportDescription
CloudSyncClientHTTP client for push/pull sync, workspace management, file operations, device registration
WsManagerWebSocket connection manager with auto-reconnect and exponential backoff
ChangeTrackerTracks pending local changes (path, action, content hash)
SyncOutboxDurable, 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/cloud-sync/src/outbox.ts) 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.

ExportDescription
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 (isolated preview fixtures, 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. The workspace store captures the source's observed server version before
   removing its local metadata. recordLocalDeleteDurable(path) runs the same
   echo-loop guard and seals that EditBase with the delete in the SyncOutbox.
   enqueueDelete supersedes any queued-but-unflushed upsert for the same
   path while retaining its original base. A locally created path can use
   base zero only when its queued creation proves that provenance.
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 sends DELETE /files/:encPath with X-Base-Version. A stale live
   head returns 409: the delete remains sealed and held, and Review offers
   Restore Incoming or an explicit Delete Again against the reviewed version.
   Unknown authority never sends a DELETE; a verified live head becomes an
   actionable conflict. A 404 or failed probe does not prove absence and
   remains retryable. A proven tombstone needs no further delete. Network
   failures stay queued with backoff. Deletes never use 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:

  1. 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.

  2. 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).

  3. 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.

  4. awaitFlush(workspaceId) drains every sendable entry and reports held conflicts separately. State-capturing operations (Note History / Checkpoints reads, restores) must inspect that result before treating the server head as current.

  5. 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 localStorage medium 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 timers

Message Types

Client to Server:

TypeFieldsDescription
hellocursor, deviceIdInitial handshake after connection
ping—Keep-alive (every 30 seconds)

Server to Client:

TypeFieldsDescription
welcomecursor, deviceCountConnection acknowledged
changeschanges[], cursorNew changes from other devices
pong—Keep-alive response
errorcode, messageServer-side error
device_connecteddeviceId, deviceNameAnother device came online
device_disconnecteddeviceIdAnother 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:

TypeScenario
concurrent-editFile modified on both local and remote since last sync
edit-deleteFile modified locally, deleted remotely
delete-editFile deleted locally, modified remotely

Resolution Strategies

Manual (default):

Users choose per-conflict via the SyncConflictDialog:

StrategyResult
use-localKeep the local version
use-server / accept-incomingReplace with the server version
save-bothCreate both <name>.local.<ext> and <name>.remote.<ext>
three-way-mergeAttempt 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 under assets-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 24 hours, never on disk; a restart or eviction can end a session sooner. 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_agent keeps 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-app/renderer/lib/workspace-key-store.ts 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.

  • Native iOS: ordinary app-private generic-password Keychain items under the running bundle identifier, with WhenUnlockedThisDeviceOnly accessibility, no synchronization or shared access group. No Secure Enclave wrapping or biometric policy is used. Unsupported commands, invocation failures and denied writes never create plaintext fallback. Locked or interaction-required reads reject explicitly; callers handle the storage error without arming or deleting persisted material. Missing items require normal unlock.

  • Android: Keystore-backed AES-256-GCM storage under a non-exportable key.

  • Web: per-workspace localStorage remains plaintext at rest, an accepted pre-release tradeoff readable by JavaScript in the browser realm.

Residual risk and release gate

Native storage protects keys at rest. The master key and derived keys still exist in memory while unlocked, and an authorized renderer can request the key through IPC. Keychain does not provide memory or XSS isolation. Actual provisioned iOS device, data-protection, signing and first-release acceptance remain OPEN on #4458; simulator tests and cross-compilation cannot satisfy that gate.

Before native storage is selected, known historical workspace-key, vault-key and key-session localStorage slots are removed without reading or importing old plaintext. On macOS/iOS this happens even if the native support probe fails. The user unlocks normally again; unrelated web storage is preserved.

The storage seam

workspace-key-store.ts exposes a small asynchronous KeyStoreBackend interface (write / read / remove / removeAll). Supported native runtimes select the backend-bridge secure-store adapter; web selects localStorage; 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 native item account; the zudotext:sync:workspace-key:<workspaceId> key on 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).

Native sign-out deletes every generic-password item in this app's service, including account-scoped key-session handles and explicitly configured Doc Cloud tokens (canonical full-origin accounts). It preserves other Keychain services; this is service-wide deletion, not per-account isolation. Doc Cloud is disabled on mobile by default, and enabling iOS Keychain does not enable its transport or UI. Single-workspace lock deletes only that workspace item.