Skip to content

Sync and Security

Runa — the Artificer

By the end of this chapter you’ll understand:

  • Why the relay server sees only ciphertext and can never read your transactions
  • How changesets carry your data from one device to another without trusting the network
  • Which key encrypts which field, and why there are three separate keys

The core idea

Casaconomy is local-first. Every device keeps a complete copy of your data in a local SQLite database. When you add a transaction on your laptop, the app doesn’t send the transaction to a server. It seals the transaction’s details in an encrypted changeset and uploads that sealed blob to the relay. The relay records that a blob arrived and stores it. When your phone pulls the relay, it downloads the blob, decrypts it with the key only your devices hold, and replays the change into its own local database.

The relay moves boxes. It does not open them.

flowchart LR
subgraph Laptop["Laptop"]
LA[UI] --> LS[Service layer]
LS --> LI[Mutation Interceptor]
LI --> LDB[(SQLite)]
LI --> LC[Changeset Service]
LC --> LE[Encryption Provider]
end
subgraph Relay["Relay — cannot decrypt"]
RQ[(ordered ciphertext log)]
end
subgraph Phone["Phone"]
PE[Encryption Provider]
PR[Replay Engine]
PDB[(SQLite)]
PE --> PR --> PDB
end
LE -->|sealed changeset| RQ
RQ -->|pull| PE

When you enter a transaction, the Mutation Interceptor captures a changeset — a small append-only record with before and after snapshots of the row. The Changeset Service assigns it a sequence number and hands it to the Encryption Provider. The Encryption Provider seals the payload. The sealed blob goes to the relay. The relay orders it, stores it, and makes it available for other devices to pull. Nothing else happens on the relay side.

Changesets, not rows

The relay never receives full table dumps. It receives changesets. A changeset describes a single mutation: which entity changed, when, and what the before-and-after state looked like. The payload is encrypted before it leaves the device.

{
"id": "uuid",
"vault_id": "uuid",
"sequence": 42,
"origin_device": "device-uuid",
"origin_user": "user-uuid",
"timestamp": "2026-05-01T09:15:00Z",
"operation_type": "insert",
"entity": "transactions",
"entity_id": "txn-uuid",
"payload_encrypted": "<sealed bytes — relay cannot read this>",
"schema_version": 1
}

The relay can see the metadata: which vault, which sequence, which device sent it, and when. It cannot see the payload_encrypted content — the amount, category, description, card number, or anything from your actual transaction. That is the encryption boundary. The relay is deliberately blind to your financial data.

Changesets are append-only. Once a changeset has a sequence number, it is never mutated or deleted in the relay log (until compaction). This makes the sync log auditable and conflict-safe: when two devices edit different fields of the same transaction simultaneously, both changesets arrive at the relay with their own sequence numbers, and the replay engine on each device applies both. Conflicts are rare and resolved field-by-field using last-writer-wins on timestamps.

Three keys, three responsibilities

Three kinds of keys exist in the system. Each has a distinct scope and responsibility.

flowchart TB
GK["KeyId::Group(uuid)\nShared among group members"]
UP["KeyId::UserPrivate(user_id)\nOwned by one user across their devices"]
DK["KeyId::Device(device_id)\nOwned by one device only"]
GK -. "encrypts shared fields\n(amount, date, category)" .-> Shared[Shared fields]
UP -. "encrypts private fields\n(card no., account no.)" .-> Private[Private fields]
DK -. "signs every outbound changeset" .-> Sig[Changeset signature]

Group key — one per group (household, trip). Every current member of the group holds a copy. Encrypts the shared fields of group-tagged transactions: amount, date, category, description, and who was involved. A new group member receives the group key through the pairing channel; a removed member’s copy is considered expired (key rotation is tracked per group epoch).

UserPrivate key — one per user. Only that user’s devices hold it. Encrypts the private fields of any transaction: card number, account number, provider transaction ID, and provider metadata. Even inside a shared group, these fields stay private to the user who created the transaction. Another household member can see you spent €42 at IKEA on Saturday — they cannot see which card you used.

Device key — one per device. Never leaves that device. Used exclusively to sign outbound changesets. The relay and other devices verify the signature to confirm the changeset came from who it claims. A compromised device can be revoked: its device key is marked invalid, and future changesets signed with that key are rejected.

Keys live in the OS secure enclave: macOS Keychain, iOS/Android Keystore, Windows DPAPI. They are never sent to the relay.

The sync loop

Every 60 seconds, and whenever a local write triggers it:

sequenceDiagram
participant A as Device A
participant R as Relay
participant B as Device B
A->>R: push sealed changesets since last ack
R-->>A: ack(latest_sequence)
B->>R: pull changesets since cursor
R-->>B: sealed changesets
B->>B: decrypt → replay → update SQLite

Push and pull are independent. Device A does not know Device B exists. The relay is the single coordination point — it orders changesets and serves them to any device that holds a valid vault token. A device that was offline for a week pulls everything it missed in one batch and replays forward.

New-device bootstrap and snapshots

A brand-new device cannot replay the full changeset history from scratch — for a long-lived vault, that could be years of changesets. Instead, the relay stores periodic snapshots: compressed, encrypted full-state checkpoints at specific sequence numbers. A new device pulls the latest snapshot and only the changesets that arrived after it, then replays forward from there.

Snapshots are bootstrap-only — used once per device lifetime. We compress aggressively (zstd at high level) because the one-time decompression cost on setup is worth the storage savings on the relay. The order matters: serialize → compress → encrypt. Encrypting first produces random-looking ciphertext that zstd cannot compress.

Worked example

You add a €42 transaction at IKEA on your laptop while your phone is offline. Here is the exact path it takes.

  1. Mutation Interceptor fires. The service layer writes the row to the local SQLite. The Interceptor captures a changeset: entity=transactions, operation_type=insert, entity_id=<txn-uuid>, before=null, after={amount:4200, date:..., category:shopping, ...}.

  2. Field split. The transaction has group_id set (household group). Shared fields (amount, date, category, description) are bundled under KeyId::Group. Private fields (card_id, account_number) are bundled under KeyId::UserPrivate.

  3. Encryption. The Encryption Provider seals each bundle with AEAD (libsodium XChaCha20-Poly1305 in Phase 3b; mock today). The KeyId is encoded in the payload envelope so the receiver knows which key to try. The Device key signs the outer envelope.

  4. Push. The Sync Service uploads the sealed changeset to the relay. The relay assigns it sequence=43 in the vault’s log and returns an ack.

  5. Phone comes online. The Sync Service on the phone pulls changesets since its last cursor. It receives the blob for sequence 43.

  6. Decrypt and replay. The phone’s Encryption Provider decrypts the group bundle with its copy of the group key, and the user-private bundle with the UserPrivate key. The Replay Engine writes the transaction row to the phone’s local SQLite. The phone’s UI now shows the IKEA transaction.

The relay held an opaque blob from step 4 to step 5. It never knew the amount, the category, or the card.

Recap

  • The relay moves sealed changesets — it never decrypts them and never sees your financial data.
  • Three keys with distinct scopes: group key for shared fields, user-private key for sensitive fields, device key for signing.
  • Sync is a 60-second push/pull loop; a new device bootstraps from a snapshot, not the full history.

What changed {#what-changed}

This chapter was introduced as the canonical sample chapter for the Casaconomy Book in CAS-3637 Phase 2. It replaces the earlier engineering-spec document of the same name. The security model described here is the target architecture; implementation status for AEAD crypto is tracked in CAS-496.

See: CHANGELOG → 2026-05-18