Storage
coven syncs over a CloudHome: a small trait that moves opaque encrypted bytes between a device and storage the user already controls. coven owns encryption, the key layout, and retry; the trait is the raw byte boundary below all of that. A CloudHome never sees plaintext, never assigns sequence numbers, and never coordinates concurrent writers. It reads, writes, lists, and deletes objects addressed by a flat string key.
That byte interface is sufficient for MergeConcurrent. Serial uses a second, deliberately separate CoordinationStorage capability for one mutable global head. Keeping it separate makes provider eligibility explicit: a CloudHome implementation cannot accidentally claim conditional-write semantics by returning a runtime error from optional methods.
Examples use the todos app. Its encrypted changesets, snapshots, attachment blobs, and membership records all land in one cloud home under keys like changes/dev1/42.enc.
What the host configures
The host selects one provider at a time and fills its settings in CloudHomeConfig, held on Config. coven reads the current config fresh on each operation rather than caching its own copy, so a provider swap or disconnect takes effect on the next sync cycle without rebuilding the sync layer.
With no provider selected there is no sync layer at all; the store is local-only and complete. At connect time create_cloud_home reads the selected provider's settings from config and its credentials from the OS keyring; a missing setting or credential fails with a Storage error naming the field ("S3 bucket not configured", "Google Drive OAuth token not in keyring").
The trait
A rich storage interface would mean five implementations of encryption, ordering, and retry, each subtly different, with the bugs living in the least-tested backend. So the trait is deliberately dumb. Everything that has to be correct lives above it, written once; a backend supplies bytes by key, plus the two provider-shaped concerns no wrapper can hide (uploads and sharing).
pub trait CloudHome: Send + Sync {
async fn probe(&self) -> Result<(), CloudHomeError> { /* default: no-op list */ }
// Uploads: one bounded request, or a streaming multipart session.
async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
async fn open_multipart<'a>(&'a self, key: &str, total_len: u64)
-> Result<BoxPartSink<'a>, CloudHomeError>;
fn multipart_threshold(&self) -> u64;
// Provided: picks put_object vs multipart and pumps the parts.
async fn write(&self, key: &str, body: BlobBody, progress: &UploadProgress<'_>)
-> Result<(), CloudHomeError> { /* central driver */ }
async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
async fn read_range(&self, key: &str, start: u64, end: u64)
-> Result<Vec<u8>, CloudHomeError>;
async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
async fn set_access(&self, desired: CloudAccessState)
-> Result<CloudAccessOutcome, CloudHomeError>;
}probechecks that the backend is reachable with the configured credentials. Setup flows call it before persisting credentials, so a typo or a missing bucket fails at setup instead of via a delayed reconnect banner. The default implementation lists a sentinel prefix; backends override it with a cheaper check (S3 usesHeadBucket).- A provider implements two raw upload pieces:
put_object, one bounded single-request upload, andopen_multipart, a streaming session that accepts ordered parts.multipart_thresholdis the cut between them. The providedwritemethod is the one coven calls: a central driver that picks the path by size and pumps a sized [BlobBody] through it, reporting cumulative bytes to theprogresscallback for the per-file bar. Small control files (auth keys, head pointers, the snapshot) passno_progress, which discards the reports. readreturns the whole value.read_rangereturns a half-open byte range (startinclusive,endexclusive), which is how coven fetches only the encrypted chunks covering a blob byte range.listreturns every key under a prefix.deleteis not an error when the key is absent.existsis a presence check.set_accesssets whether one member principal can reach the cloud home. The command carries the absolute desired state, verifies provider readback, and is safe to retry after an unknown outcome. It is provider-shaped and described below.
Serial coordination
CoordinationStorage requires four operations: read a head with its provider version, create only if absent, replace only when that exact version still matches, and delete a reserved probe head. Setup exercises two independent clients against a fresh reserved key and requires one winner for both the create race and the replace race, followed by authoritative readback and cleanup.
CloudKit and AWS S3 expose this capability. An S3-compatible endpoint is not assumed to have AWS's conditional writes and strong reads: the host must set CloudHomeConfig::s3_serial to CustomS3Serial::ConditionalPutAndStrongReads. Google Drive, Dropbox, and OneDrive have no Serial coordination adapter. Creating, joining, restoring, or starting Serial sync on those providers is refused before provider access or local mutation.
The choice belongs to the signed Store, not to the local connection. A store can be opened and used locally with either policy while no provider is configured; the coordination requirement begins when Serial sync connects.
The signed global head is an authoritative chain selector, not a filename winner. Pull opens every visible physical candidate in the head slot, requires copies under one semantic hash to contain identical plaintext, rejects multiple valid hashes as a fork, and follows only the exact predecessor chain named by the accepted head. Provider listing order never selects a Serial history.
Granting and revoking access
Membership is cryptographic, but a new member still has to reach the bytes: the storage itself must admit them. That is inherently provider-shaped (a folder share, a credential, a share URL), so it lives on the trait.
set_access(CloudAccessState::Present { ... }) carries the member's public key plus the provider account email for backends that share by account, and returns a CloudHomeJoinInfo, one variant per provider, carrying exactly what another device needs to reach the same cloud home:
- The consumer clouds (Drive, Dropbox, OneDrive) share the store folder with the member's provider account and return its folder or drive id. setting access to
Absentunshares it and reportsRevokeOutcome::Revoked. - S3 returns the bucket, region, endpoint, access key, secret key, and optional key prefix: access rides pre-shared credentials. One member's copy of a shared key cannot be withdrawn alone, so setting access to
AbsentreportsRevokeOutcome::Unsupportedand removal proceeds anyway: the key rotation that removal performs, not credential withdrawal, is what protects post-removal content. Cutting the removed member's residual write access means rotating the bucket credentials, which is the user's call. - CloudKit returns a share URL.
set_access(CloudAccessState::Absent { ... }) withdraws access where the provider supports per-member revocation and reports a RevokeOutcome. Because access updates work with folder shares and share URLs, not encrypted payloads, they live below the encryption layer and are called directly on the CloudHome, not through the wrapper described under Where encryption sits.
Errors
AccessDenied means nothing to the person looking at a sync banner, and the host can't translate it either; it doesn't know S3 from Dropbox. So every failure crosses this boundary as a sentence a UI can show verbatim.
pub enum CloudHomeError {
NotFound(String),
Configuration(String),
Transport(String),
Io(#[from] std::io::Error),
}NotFound(key): the key is not there. coven uses it for the expected misses (no snapshot yet, a blob not uploaded yet), so a host that maps it to a UI state matches the variant directly.Configuration(msg): missing or invalid settings, credentials, OAuth authorization, or provider capability. Retrying the same request cannot succeed until configuration changes.Transport(msg): a backend, network, response, or service failure that may succeed when the initiating operation retries.Io: a local filesystem or I/O failure surfaced fromstd::io::Error.
Each driver classifies the failures a user can act on. For example, S3 AccessDenied becomes "Your S3 credentials don't have permission to write to this bucket. Check the access policy in sync settings."; NoSuchBucket becomes "The S3 bucket no longer exists. Check the bucket name in sync settings."; and Backblaze or MinIO OverQuota/QuotaExceeded becomes a quota message (AWS rarely returns these). The consumer clouds do the same for their full-storage codes (storageQuotaExceeded, path/insufficient_space, quotaLimitReached). Every other service error keeps its raw code and message so it stays debuggable in logs.
Above the raw CloudHome boundary, blob reads retain three distinct causes. A provider or network failure is transport and sets the sync loop to Offline. Plaintext that fails its signed content hash is InvalidContent; failure to create, write, sync, or rename the local destination is LocalFilesystem. Invalid content and local filesystem failures hold or fail the affected work without reporting that storage is offline. Inline host-blob uploads, snapshot uploads, row-gate make_remote uploads, and candidate blob downloads all keep provider transport typed through this boundary.
Providers
Five cloud backends ship, plus an in-memory home for tests. Each maps the same flat keys onto its own naming and upload protocol; the differences below are the only places a provider deviates from "write opaque bytes by key".
S3 (
S3CloudHome) works against any S3-compatible endpoint (AWS, Backblaze B2, Wasabi, MinIO). Files at or below 8 MiB go up as a singlePutObject; larger files use a multipart upload with 8 MiB parts, reporting progress per completed part and aborting the in-progress upload on failure so the bucket holds no orphaned parts. An optional key prefix is prepended to every key (trailing slashes normalized), sochanges/dev1/42.enccan becomelibs/abc/changes/dev1/42.enc. AWS S3 also supplies the conditional global-head adapter used bySerial. A custom endpoint supplies it only with the explicits3_serialassertion described above.Google Drive (
GoogleDriveCloudHome) stores files flat in one folder. Drive filenames cannot carry the key's slashes, so each key is hex-encoded into a slash-free filename and decoded on list; the encoding is exact and reversible, never a lossy substitution. Large files use a resumable upload session in 8 MiB chunks (Drive requires 256 KiB alignment). It supportsMergeConcurrent, notSerial.OneDrive (
OneDriveCloudHome) uses the same hex filename encoding and a Microsoft Graph resumable upload session in 7.5 MiB chunks (Graph requires 320 KiB alignment). It supportsMergeConcurrent, notSerial.Dropbox (
DropboxCloudHome) uses native Dropbox paths under the store folder (for example/Apps/your-app/my-store/changes/dev1/42.enc), so no filename encoding is needed. Sharing goes throughshare_folderto get ashared_folder_id. It supportsMergeConcurrent, notSerial.CloudKit (
CloudKitCloudHome) stores files in the user's iCloud private database. ACKAssetcaps at 50 MB, so a file larger than 10 MiB is split into 10 MiB part records and read back by reassembling those parts; a failed multipart upload deletes the part records it wrote. The raw record operations are defined by theCloudKitOpstrait and implemented in Swift through a UniFFI callback interface;create_cloud_homecannot build this one from Rust alone and returns aStorageerror directing you to construct it through your Swift layer. Its record versions supply the conditional global-head adapter used bySerial.In-memory (
InMemoryCloudHome, under thetest-utilsfeature) is aHashMap-backed home that two simulated devices share through anArcto round-trip changesets and blobs in unit tests. It exposeskeys(),get(),len(), anddeletes_seen()for after-the-fact assertions.
OAuth sessions: refresh and retry
Drive, Dropbox, and OneDrive share their token lifecycle through an OAuth session. Each backend owns one and routes its requests through it. Before a request, the session checks the access token's expiry: if it expires within 60 seconds it refreshes first, persisting the new tokens to the keyring. After a request, a 401 triggers one refresh and one retry.
The session also absorbs transient pressure: a 429 or any 5xx retries up to four times with exponential delay (500 ms doubling, capped at 32 seconds, honoring a server-supplied Retry-After), so routine quota throttling degrades a cycle to slow rather than failed, while a hard outage exhausts the attempts in seconds and fails loud to the cycle's own minutes-long backoff.
When the refresh itself fails because the grant is gone (refresh token revoked, expired, or the account password changed), the underlying OAuthError::Reauthorize surfaces as a Storage message: "Your {provider} access was revoked or expired. Reconnect to keep syncing." That is a user-facing message, not a transient network error, so a host should wire it to a reconnect affordance rather than retrying. A session missing its refresh token entirely produces the same kind of reconnect message.
Where encryption sits
Encryption stays out of the providers so that all five share one at-rest implementation instead of five slightly different ones. CloudHome deals only in raw bytes. The at-rest protection and the key layout live one level up, in CloudSyncStorage, which wraps any dyn CloudHome: it seals on the way down, opens on the way up, and owns the mapping from Store protocol objects, blob ids, and wrapped member keys to the flat keys the trait stores. Both how it seals (the CloudCipher) and how it keys blobs (the BlobPathScheme) come from the home's storage mode, one choice set when the home is created:
- An opaque home (the default) encrypts every object under the store key and adds the
.encsuffix beneath logical Store paths such asstore-v1/candidates/{family}/packages/{device}/{seq}/{hash}.pkgandstore-v1/snapshots/{author}/{hash}.json. It keys each blob by its content-addressed shard{namespace}/{ab}/{cd}/{id}. The provider sees ciphertext under protocol coordinates and opaque blob keys. - A browsable home stores every object verbatim and drops the suffix, so the same Store objects are at their bare logical names and stores each blob at the consumer's readable
{namespace}/{cloud_path}. Anyone with bucket access reads the actual bytes by name.
Ranged reads
A host that streams a large blob (audio playback, scrubbing) needs a byte window from the middle of the file without downloading and decrypting everything before it. The cache's open_blob_stream serves a window from the local file on a hit, and on a miss reads it from the cloud through SyncStorage::read_blob_range, which is backed by a BlobRangeReader.
On an opaque home a blob is stored as [nonce: 24 bytes][encrypted chunks...]. The reader fetches the 24-byte base nonce once on the first read and reuses it, then for each window range-reads only the encrypted chunks covering it and decrypts them (see chunked encryption). So streaming a blob in N windows issues one nonce read, not N, and never pulls the whole object. On a browsable home the blob is stored verbatim, so a window is read straight through with no nonce or decryption. The blob's scope is resolved to its key once when the reader is built, so master-, derived-, and item-key blobs all stream the same way.
Lifecycle
handle.connect_sync(...) builds the cloud home from the current config and spawns the sync loop when a provider is configured. handle.stop_sync() drops the loop, and handle.start_sync() starts it again. Because the config is read fresh each operation, swapping providers is a config change followed by a stop/start, with no app restart.