3.3 KiB
Sync Tenant Isolation Design
Status: approved for implementation by the project owner on 2026-07-10.
Goal
Make sync operations private to the authenticated user and vault, and make the server, not the request body, the authority for the originating device.
Scope
- Bind new desktop pairings to the current vault ID.
- Persist
user_idandvault_idwith every sync operation. - Scope push conflict detection, pull cursors, tombstones, and idempotency responses to that pair of identifiers.
- Ignore the legacy
device_idfield in a push body for authorization and storage; retain it in the wire format for backward-compatible decoding. - Upgrade existing SQLite databases without deleting data.
Blob ownership, API-key retirement, reset-token handling, HTML escaping, and upload limits are separate security slices and are deliberately not included in this change.
Data model
server_devices gains a nullable vault_id. A device created through
/api/client/pair must have a non-empty vault ID. The authenticated device
therefore identifies one user and one vault.
server_ops gains user_id and vault_id. New writes always set both from
the authenticated device. Pull and conflict queries filter both fields.
server_tombstones is rebuilt with a composite key of
(user_id, vault_id, entity_type, entity_id). server_idempotency_keys is
rebuilt with a composite key of (user_id, vault_id, idempotency_key).
Existing devices without vault_id use the explicit effective scope
legacy:<user_id>. Existing operations inherit their device owner and that
legacy scope during startup migration. This preserves existing single-vault
accounts while preventing data from crossing account boundaries. New pairings
never use the legacy scope.
API contract
POST /api/client/pair accepts a required vault_id. The desktop gets it
from .verstak/vault.json and sends it while pairing.
POST /api/v1/sync/push keeps accepting device_id for old clients, but the
server ignores it. The stored operation device ID is always the authenticated
device. A request whose token is not associated with a user and effective
vault returns a client error.
POST /api/v1/sync/pull returns only operations from the authenticated
user/vault scope. server_sequence is the highest sequence in that scope;
global sequence gaps are not exposed as the caller's cursor.
Migration and failure handling
Startup migration is idempotent. It checks SQLite table columns before adding
new operation/device fields, backfills user_id from each operation's device,
and assigns the explicit legacy scope when an old device has no vault ID.
Tables whose primary key must change are rebuilt transactionally.
If an operation cannot be associated with a user, it remains unscoped and is not readable through sync APIs. The server must not guess an owner from a request body.
Verification
Focused server tests must prove:
- two users cannot pull each other's operations;
- two vaults of one user cannot pull each other's operations;
- a caller cannot forge another device through the push body;
- scoped idempotency does not replay another tenant's response;
- a legacy SQLite database is upgraded with its existing operation retained in the matching legacy scope.
Desktop tests must prove that pairing sends the opened vault's persistent ID.