docs · docs/30-core/PROFILE_STORAGE_SYNC.md · synced 2026-09-10

Profiles and sync

signed, encrypted blobs; the lifecycle; version vectors; what a running profile never does

Purpose: define the profile lifecycle, on-disk/at-rest forms, and the sync protocol. This is the model proven by Vision (<uuid>.zip + <uuid>.sig, partial local materialization) and formalized here (ADR-0004).

1. Two forms of a profile

  • Runtime form: a live user-data-dir Chromium can use (cookies, localStorage, IndexedDB…).
  • Storage/sync form: a Profile Blob = manifest + payload:
    • payload = user-data-dir → deterministic archive (tar/zip) → compressedencrypted (AEAD).
    • <uuid>.blob (ciphertext) + <uuid>.sig (signature over ciphertext+manifest).
    • manifest (cleartext, integrity-protected): uuid, version vector, size, core version last used, fingerprint id, createdAt, deviceId, contentHash.

Bridges between the two: materialize (blob → dir) and pack (dir → blob). They are the only sanctioned conversions.

2. Lifecycle state machine

            create / import
                  │
            ┌─────▼──────┐  pull(blob)     ┌──────────────┐ materialize ┌───────────────┐
            │ CLOUD_ONLY │────────────────►│ LOCAL_CACHED │────────────►│ MATERIALIZED  │
            └─────▲──────┘                 └──────▲───────┘             └──────┬────────┘
                  │ evict                          │ pack                       │ start
                  │                                │                            ▼
          ┌───────┴────────┐  upload        ┌──────┴───────┐   stop      ┌──────────────┐
          │ SYNCING_UPLOAD │◄───────────────│    DIRTY     │◄────────────│   RUNNING    │
          └────────────────┘                └──────────────┘             └──────────────┘
   remote newer │                                  ▲
                ▼                                   │ resolve
        ┌────────────────┐                   ┌──────┴──────┐      lock held elsewhere
        │ SYNCING_DOWNLOAD│                  │  CONFLICT   │◄──────  LOCKED_REMOTE
        └────────────────┘                   └─────────────┘

State definitions:

  • CLOUD_ONLY — only the blob exists in cloud; nothing local.
  • LOCAL_CACHED — blob present locally, not unpacked.
  • MATERIALIZED — live dir exists, not running.
  • RUNNING — a core is using the dir (exclusive; no sync may touch it).
  • DIRTY — stopped with local changes not yet uploaded.
  • SYNCING_UPLOAD/DOWNLOAD — transfer in progress.
  • CONFLICT — local and remote diverged (both changed since common base).
  • LOCKED_REMOTE — another device holds the run/edit lock.

3. Encryption & signing (zero-knowledge, multi-KEK — ADR-0008)

  • Envelope hierarchy: each profile payload is AEAD-encrypted with a random per-profile Data Key (DEK). The DEK is wrapped independently by multiple KEKs — any one unwraps it. Server stores only ciphertext + the wrapped-DEK copies → server cannot decrypt on any path.
  • KEK factors: password (Argon2id) + a mandatory Recovery Key (offline emergency kit); optional passkey (WebAuthn PRF) and OS keychain; team profiles add an org-key KEK (public-key wrap) for admin-assisted recovery — still zero-knowledge to the server.
  • AEAD (AES-256-GCM or XChaCha20-Poly1305) for payload; signature (Ed25519) over manifest+ciphertext for tamper/anti-corruption detection (the .sig).
  • Recovery: lose one factor → recover via another. Lose all factors → lose the profiles (honest E2EE floor). No server-side escrow. Details: KEY_RECOVERY_COMPARISON.md, SECURITY_POLICY §4.

4. Sync protocol

  1. Lock: sync.lock.acquire(id) before upload/edit. Locks are leased (TTL) + owner-stamped.
  2. Version vectors: each blob carries {deviceId: counter}; compare to detect fast-forward vs divergence.
  3. Upload: pack → encrypt → sign → multipart upload to object storage → commit manifest in metadata DB.
  4. Download: fetch blob → verify sig → decrypt → materialize on demand.
  5. Conflict: if remote advanced beyond local base while local is DIRTY → CONFLICT. Default resolution: last-writer-wins guarded by lock; CONFLICT keeps both blobs (a/b) for manual pick.
  6. Eviction: LOCAL_CACHED/MATERIALIZED can be evicted to CLOUD_ONLY under disk pressure (LRU), never if DIRTY/RUNNING.

5. Local-first guarantee

With no cloud account, everything works: profiles live as local blobs + dirs, lifecycle minus the SYNCING/LOCKED_REMOTE states. Cloud is an additive layer, never a dependency (ARCHITECTURE.md §1.3).

6. Invariants (enforced by Profile Lifecycle, never bypassed)

  • A RUNNING profile is never packed/uploaded/evicted.
  • Sync never edits a live user-data-dir (DO_NOT_DO.md).
  • Materialize/pack are the only dir↔blob conversions.
  • Every state transition emits a profile.state_changed event + (for dangerous ones) an audit entry.
  • Materialized = the only plaintext window. While MATERIALIZED/RUNNING the user-data-dir is plaintext on disk (Chromium needs it). On pack/evict the dir is securely wiped; the window is kept minimal. (Local-compromise residual risk: THREAT_MODEL §4.)
  • materialize never hands a raw path to remote clients. The path goes only to the local UI/owner context; CLI/MCP/script clients get an opaque handle, and any raw-path return is power mode + audited — a path would otherwise bypass the single control boundary.

Open questions

Archive determinism across OSes, partial/delta sync vs full-blob, lock TTL + steal policy, conflict UX, key recovery options (social/escrow?) → OPEN_QUESTIONS.md.