# Spec: @suaveplan/vault-sync ## Capability Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem. Builds on `@suaveplan/crdt` and `@suaveplan/diff` (JSON Patch) to provide offline-first, end-to-end-encrypted vault synchronisation. Implements vault-item merge strategies: last-write-wins for passwords and metadata, append-only for audit history and TOTP counter increments, and atomic move/rename for folder and item relocations. ## Requirements ### Requirement: CRDT-based document merge for vault items The package SHALL export `mergeVaultItems(local: VaultItem, remote: VaultItem, strategy: MergeStrategy): VaultItem` which applies a CRDT merge to reconcile divergent copies of the same vault item. The merge SHALL be commutative, associative, and idempotent so that peers applying the same pair of items in any order converge to the same result. `MergeStrategy` MUST support `"lww"` (last-write-wins), `"append"` (append-only set), and `"atomic"` (atomic replace). Unrecognized strategies MUST throw `VaultSyncError` with code `VAULTSYNC.MERGE.UNKNOWN_STRATEGY`. #### Scenario: Last-write-wins selects the later timestamp for password fields - **WHEN** two `VaultItem` objects with the same `id` have different `password` values and different `modifiedAt` timestamps - **THEN** `mergeVaultItems` with strategy `"lww"` returns the password from the item with the later `modifiedAt` #### Scenario: Append-only merges history without losing entries - **WHEN** two `VaultItem` objects have divergent `history` arrays - **THEN** `mergeVaultItems` with strategy `"append"` returns a union of both histories sorted by `timestamp`, with deduplication by `historyId` #### Scenario: Atomic strategy replaces the entire object on conflict - **WHEN** two `VaultItem` objects have conflicting `folderId` values - **THEN** `mergeVaultItems` with strategy `"atomic"` returns the remote copy in its entirety, discarding local changes to that field #### Scenario: Merge is commutative - **WHEN** `mergeVaultItems(local, remote, strategy)` and `mergeVaultItems(remote, local, strategy)` are both called - **THEN** both calls produce deeply equal results #### Scenario: Unknown strategy is rejected - **WHEN** `mergeVaultItems` is called with an unrecognized strategy name - **THEN** a `VaultSyncError` with code `VAULTSYNC.MERGE.UNKNOWN_STRATEGY` is thrown **Verified by:** `src/merge/merge.test.ts::"lww selects later timestamp for password"` AND `src/merge/merge.test.ts::"append merges history without duplication"` AND `src/merge/merge.test.ts::"append sorts merged history by timestamp"` AND `src/merge/merge.test.ts::"atomic replaces entire object on conflict"` AND `src/merge/merge.test.ts::"merge is commutative"` AND `src/merge/merge.test.ts::"merge is idempotent"` AND `src/merge/merge.test.ts::"unknown strategy throws"` --- ### Requirement: JSON Patch diff and apply for incremental sync The package SHALL export `diffVaultItems(before: VaultItem, after: VaultItem): JsonPatchOperation[]` producing a JSON Patch (RFC 6902) document representing the minimal set of changes needed to transform `before` into `after`. `applyPatch(item: VaultItem, patch: JsonPatchOperation[]): VaultItem` SHALL apply the patch immutably and return the modified item. Patch application MUST validate that array indices are in bounds and that `replace`/`remove` operations target existing paths; invalid patches MUST throw `VaultSyncError` with code `VAULTSYNC.PATCH.INVALID`. #### Scenario: Diff produces minimal patch for field change - **WHEN** `diffVaultItems` is called with two items that differ only in `title` - **THEN** the result contains a single `replace` operation at path `/title` #### Scenario: Diff is empty for identical items - **WHEN** `diffVaultItems` is called with two deeply equal items - **THEN** the result is an empty array #### Scenario: Apply patch transforms item correctly - **WHEN** `applyPatch` is called with a valid patch document - **THEN** the resulting item reflects all operations in order #### Scenario: Invalid patch operation is rejected - **WHEN** `applyPatch` is called with a `replace` operation targeting a non-existent path - **THEN** a `VaultSyncError` with code `VAULTSYNC.PATCH.INVALID` is thrown **Verified by:** `src/patch/patch.test.ts::"diff produces single replace for title change"` AND `src/patch/patch.test.ts::"diff is empty for identical items"` AND `src/patch/patch.test.ts::"apply patch transforms item correctly"` AND `src/patch/patch.test.ts::"rejects replace on missing path"` AND `src/patch/patch.test.ts::"rejects remove on missing path"` AND `src/patch/patch.test.ts::"rejects out-of-bounds array index"` AND `src/patch/patch.test.ts::"round-trip diff then apply yields original"` --- ### Requirement: Sync protocol — pull, push, and bidirectional reconciliation The package SHALL export `SyncEngine` with an optional constructor options bag `{ clock?: Clock; rng?: Rng }` and methods `pull(remoteSnapshot: VaultSnapshot): Promise`, `push(): Promise`, and `sync(remoteSnapshot: VaultSnapshot): Promise`. `pull` SHALL fetch the remote snapshot, compute a diff against the local state, apply remote wins for `"lww"` fields and `"append"` for history, and return a `SyncResult` listing `merged`, `conflicts`, and `deleted`. `push` SHALL serialize local changes since the last successful sync into an encrypted blob and return it. `sync` SHALL perform a bidirectional reconciliation: push local changes first, then pull remote changes, resolving any conflicts with the configured strategy. All network transport is delegated to the caller; `SyncEngine` operates on in-memory snapshots. #### Scenario: Pull applies remote changes to local state - **WHEN** `pull` is called with a remote snapshot containing one new item and one updated item - **THEN** the local state reflects the new item AND the updated fields, and `SyncResult.merged` lists both #### Scenario: Push returns only changed items since last sync - **WHEN** `push` is called after local modifications to two items - **THEN** the returned encrypted blob contains only those two items and `SyncResult.merged` lists them #### Scenario: Bidirectional sync reconciles both directions - **WHEN** `sync` is called with a remote snapshot that has diverged from local state in both directions - **THEN** the resulting local state contains the union with conflicts resolved by strategy AND `SyncResult` reports both merged and conflict-resolved items #### Scenario: Deleted items are propagated - **WHEN** a item is marked deleted locally and `push` is called - **THEN** the sync payload includes a tombstone AND `SyncResult.deleted` lists the item id #### Scenario: Injectable clock and RNG are accepted - **WHEN** `SyncEngine` is constructed with `clock` and `rng` options - **THEN** construction succeeds and the options are used for timestamp generation and nonce generation respectively **Verified by:** `src/engine/engine.test.ts::"pull applies remote changes"` AND `src/engine/engine.test.ts::"push returns only local changes since last sync"` AND `src/engine/engine.test.ts::"bidirectional sync reconciles divergent states"` AND `src/engine/engine.test.ts::"propagates deletions as tombstones"` AND `src/engine/engine.test.ts::"detects concurrent edits as conflicts"` AND `src/engine/engine.test.ts::"resolves conflicts with configured strategy"` AND `src/engine/engine.test.ts::"empty pull returns empty result"` AND `src/engine/engine.test.ts::"empty push returns empty result"` AND `src/engine/engine.test.ts::"accepts injectable clock and rng"` --- ### Requirement: Offline-first semantics and sync-queue durability The package SHALL maintain a durable `SyncQueue` that accepts optional constructor options `{ clock?: Clock; rng?: Rng }` and records every local mutation (create, update, delete) as an operation with a monotonic vector clock. When offline, operations SHALL accumulate in the queue without blocking callers. On reconnection, `SyncEngine.flush()` SHALL replay the queue in order, collapsing redundant operations (e.g. two updates to the same field become the latest update only). The queue MUST be serializable to and restorable from `Uint8Array` so that `@suaveplan/db-sqlite` can persist it. Vector clocks MUST use peer ids and Lamport timestamps to establish partial ordering. #### Scenario: Mutations queue while offline - **WHEN** several item updates occur while the sync engine is offline - **THEN** all updates are appended to the `SyncQueue` AND no network calls are attempted #### Scenario: Flush replays queued operations on reconnect - **WHEN** `flush` is called after a period of offline mutations - **THEN** the queue is drained in order AND each operation is included in the next push payload #### Scenario: Redundant updates are collapsed - **WHEN** the queue contains three updates to the same item's `password` field - **THEN** `flush` collapses them into a single update containing only the final password value #### Scenario: Queue survives serialization round-trip - **WHEN** the `SyncQueue` is serialized to bytes and then restored - **THEN** the restored queue contains the same operations in the same order with identical vector clocks #### Scenario: Injectable clock and RNG are accepted - **WHEN** `SyncQueue` is constructed with `clock` and `rng` options - **THEN** construction succeeds and the options are used for vector-clock timestamps and nonce generation **Verified by:** `src/queue/queue.test.ts::"mutations queue while offline"` AND `src/queue/queue.test.ts::"flush replays queued operations"` AND `src/queue/queue.test.ts::"collapses redundant updates to same field"` AND `src/queue/queue.test.ts::"serializes and restores queue"` AND `src/queue/queue.test.ts::"vector clock establishes partial order"` AND `src/queue/queue.test.ts::"concurrent operations from different peers are ordered by clock"` AND `src/queue/queue.test.ts::"queue honors max size limit"` AND `src/queue/queue.test.ts::"accepts injectable clock and rng"` --- ### Requirement: End-to-end encryption of sync payloads The package SHALL export `encryptSyncPayload(payload: SyncPayload, key: Uint8Array, options?: { rng?: Rng }): EncryptedSyncPayload` and `decryptSyncPayload(encrypted: EncryptedSyncPayload, key: Uint8Array): SyncPayload`. Encryption SHALL use XChaCha20-Poly1305 (via `@suaveplan/crypto-cipher`) with a random 192-bit nonce drawn from `options.rng` when provided. The `EncryptedSyncPayload` MUST include the nonce, ciphertext, and tag. Decryption failure (bad key, tampered tag) MUST throw `VaultSyncError` with code `VAULTSYNC.CRYPTO.DECRYPT_FAILED`. The sync engine MUST never persist plaintext payloads. #### Scenario: Encrypt then decrypt round-trips - **WHEN** a `SyncPayload` is encrypted with a 32-byte key and then decrypted with the same key - **THEN** the decrypted payload deeply equals the original #### Scenario: Different key fails decryption - **WHEN** `decryptSyncPayload` is called with a different key than was used for encryption - **THEN** a `VaultSyncError` with code `VAULTSYNC.CRYPTO.DECRYPT_FAILED` is thrown #### Scenario: Tampered ciphertext is detected - **WHEN** a single byte of the ciphertext is flipped before decryption - **THEN** a `VaultSyncError` with code `VAULTSYNC.CRYPTO.DECRYPT_FAILED` is thrown #### Scenario: Injectable RNG determines nonce - **WHEN** `encryptSyncPayload` is called with a test RNG in `options.rng` - **THEN** the nonce is deterministic for that RNG **Verified by:** `src/crypto-sync/crypto-sync.test.ts::"encrypt decrypt round-trips"` AND `src/crypto-sync/crypto-sync.test.ts::"wrong key fails decryption"` AND `src/crypto-sync/crypto-sync.test.ts::"tampered ciphertext is rejected"` AND `src/crypto-sync/crypto-sync.test.ts::"nonce is unique per encryption"` AND `src/crypto-sync/crypto-sync.test.ts::"payload serialization is stable"` AND `src/crypto-sync/crypto-sync.test.ts::"injectable rng determines nonce"` ## Error codes `VAULTSYNC.MERGE.UNKNOWN_STRATEGY`, `VAULTSYNC.PATCH.INVALID`, `VAULTSYNC.SYNC.NO_SNAPSHOT`, `VAULTSYNC.QUEUE.OVERFLOW`, `VAULTSYNC.CRYPTO.DECRYPT_FAILED`, `VAULTSYNC.CRYPTO.MISSING_KEY` ## Version Published: `0.1.0` (proposed). Spec delta: `vault-sync`.