Sharing
A store can have more than one writer. coven decides who may write from the store's signed membership state. MergeConcurrent stores use causal owner streams; Serial stores put membership changes in the same globally ordered commit chain as row changes. Pull verifies each Store commit and the applicable membership state, so the cloud provider never decides who may write.
Every initialized store has signed founder membership, including a store with one writer and a browsable cloud home. Creation publishes a signed StoreProtocolRoot that binds the store id, founder membership entry, schema version, and write policy. Its store_root_hash is pinned locally and carried by every signed Store protocol object. MergeConcurrent then publishes the founder's causal membership head. Serial derives founder authorization directly from the root and creates no membership stream or head. "Browsable" describes cloud visibility and readable blob paths; it does not disable membership authorization.
coven shares a store by membership: it grants the whole store to another writer, a peer with their own identity in membership, by sealing the store keyring to that member's keypair. The store is the unit of sharing — a different set of people is a different store.
Examples use the todos app; two people both write todos, and the owner controls who else can.
Identity
With no server, there is nobody to hand out accounts or vouch for names, so an identity has to prove itself: it is a keypair, and anything it signs is its own credential. A member is an Ed25519 public key (32 bytes). Each device generates its keypair locally; there is no identity server, and the public key is the only name a member has. The same public key appears in two places coven cross-checks: in membership entries, and in the author_pubkey field of every changeset envelope. The key used for encryption (X25519) is computed from the Ed25519 key by ed25519_to_x25519_public_key, so anyone holding a member's Ed25519 public key can derive the target to wrap the store keyring to (see The store keyring).
Membership records
Anyone who ever held bucket access can write bytes, so a correctly encrypted but forged changeset is always possible, and there is no server to refuse it. Each device decides who may write from storage alone, with nothing but keys to trust. Membership changes are therefore signed records. In a MergeConcurrent store, a MembershipEntry records one change:
pub struct MembershipEntry {
pub version: u32,
pub store_id: String,
pub author_pubkey: String,
pub author_owner_grant: MembershipGrantId,
pub stream_id: AuthorStreamId,
pub seq: u64,
pub previous_hash: Option<ObjectHash>,
pub dependencies: Vec<MembershipCoord>,
pub created_at: String,
pub change: MembershipChange,
pub signature: String,
}The signature covers a deterministic serialization of every field except the signature itself. sign_membership_entry fills in author_pubkey and signature; verify_membership_entry checks them.
The created_at value is an HLC string used for display ordering, not to authorize anything. It is author-supplied and therefore spoofable, so no access decision reads it (see Revocation).
MergeConcurrent: per-author-stream commitment
Two owners inviting people at the same moment must not be able to erase each other's work. Any design with one shared "latest" object has that failure built in: the second writer wins, the first invite vanishes. So there is no shared object anywhere in membership. Entries live in streams identified by their author, the Owner grant authorizing that author, and a random stream id:
store-v1/membership/entries/{author}/{owner_grant}/{stream_id}/{seq}/…
store-v1/membership/heads/{author}/{owner_grant}/{stream_id}/{seq}/…Each owner appends entries only under a stream it created and hash-links them (previous_hash). A dependency frontier names the greatest effective coordinate in every observed stream. Dependencies are ordered by full stream identity, so one signed byte representation exists. The owner then publishes an AuthorHead: a signed statement that entries 1..=seq of that exact stream exist and that the entry at seq hashes to tip_hash. A reader admits an author's prefix only up to that author's head, so an entry is uncommitted until its own author's head covers it. The committed membership set is the causally reduced union of every signed stream prefix. If causal revocation removes a stream suffix, coven never extends that raw stream again; its author creates and persists a fresh stream id whose first entry depends on the effective frontier.
Under MergeConcurrent, every owner commits under its own stream, so concurrent owners never race each other: there is no shared last-writer-wins object one writer can overwrite over another's entry, and a failed publish leaves at most that one author's head behind its entries, never a wedged chain.
MembershipChain is rebuilt from storage on each sync, not kept in the database. Validation enforces, in order:
- The first entry must be an
Addwith roleOwner, self-signed (author_pubkey == user_pubkey). This is the founder; any other shape is rejected. A chain whose founder is not the store's established owner is a takeover attempt and is refused outright. - Every entry must carry a valid signature and a correct
previous_hashlink to the previous entry in its exact author, Owner-grant, and stream-id prefix. - Every entry after the first must be signed by an author who is a current
Ownerat that point.
Re-adding an existing pubkey with a different role overwrites the old role (a downgrade), so an owner can demote a member without removing them.
Serial: global membership commits
Serial has no membership author streams or membership heads. A membership change is a signed StoreControl inside the next global Store commit. The entry names the exact hash of the membership state it changes, the commit author must be a current Owner, and the global commit author must be a current writer.
Adding a member activates at that commit position. Removing a member and moving to the next key generation occupy one control commit, so readers cannot observe the removal without its key rotation. The signed global head activates the commit with an atomic conditional update; a stale owner receives a conflict and must rerun the operation against the current state.
Roles
MemberRole has three forms:
- Owner can write, and can mutate the chain: invite, remove, and change roles. The founder is an owner, and an owner can promote others. Any current owner can invite, not just the founder.
- Member can read and write todos, but cannot touch the chain.
- Follower holds the store keyring and reads everything, but may not write. The restriction is enforced acceptance-side: a puller re-derives each author's role from the chain and rejects a Follower's changesets.
MemberRole::can_write is true for Owner and Member, false for Follower.
The store keyring
Data is only as private as the distribution of its keys: encryption means nothing if the key travels carelessly. A store's data is encrypted under a symmetric key that can rotate; the keyring is the full set of those key generations. Each member's copy of the keyring is sealed to their X25519 public key with libsodium's sealed box and stored under the wrapping owner's own prefix, at keys/{owner_pubkey}/{recipient_pubkey}.enc in the cloud home — an owner writes only into its own prefix, which is what lets a reader trust that a wrap came from that owner. Only the holder of the matching private key can open it.
Inviting a member writes the keyring wrapped to that member at keys/{owner_pubkey}/{recipient_pubkey}.enc. The wrapped keyring names the exact membership activation: a causal entry coordinate for MergeConcurrent, or a global commit position for Serial. A joiner unwraps it only once that activation is visible. The new member downloads and unwraps their copy when they join (unwrap_store_keyring).
If any step of an invite fails partway, the steps already taken are rolled back (a previously wrapped keyring is restored, not deleted), so a failed invite never leaves a member half-added or an existing member locked out.
Pull verification
In MergeConcurrent, each signed Store commit names the exact MembershipCoord that grants its author write access. Pull verifies the commit and device head signatures, requires their authors to agree, and checks that coordinate against the founder-anchored membership chain.
When a provider listing lags behind the named grant, coven reads that exact membership object by key and verifies its hash instead of searching alternate entries. A bad signature, relocated grant, missing grant, or signer mismatch holds the exact Store position without applying rows. A valid commit whose author is no longer write-capable is rejected according to the membership state and cannot grant itself access.
In Serial, pull starts at the signed global head and verifies the complete prefix in sequence. Each commit must name its exact predecessor. Membership controls update authorization at their own commit positions; ordinary commits are checked against the state produced by the preceding prefix. A missing commit, package, signature, or predecessor stops the position without applying its rows.
An initialized store never accepts an unsigned commit or a commit whose author is unauthorized under its write policy.
Revocation is key rotation
You cannot un-send data: a removed member keeps every byte they already pulled. What removal can guarantee is that they read nothing new, and the only enforcement that needs no server and no honest clock is a key they never receive. So removal is key rotation, not a temporal replay of the chain ("was this author allowed when they claim they wrote this?"). handle.remove_member(...):
- Revokes the member's cloud access: an unshare on consumer clouds; on S3, where one holder of a shared key cannot be cut off alone, the backend reports the credential as unrevocable and removal proceeds, because the key rotation below, not credential withdrawal, is what protects new content.
- Commits the removal and the new key generation together. Under
MergeConcurrentthis is the removing owner's causal entry plus its rotation record; underSerialboth facts are one global control commit. - Re-wraps the updated keyring to every remaining member under the removing owner's own prefix, at
keys/{owner_pubkey}/{member_pubkey}.enc. - Deletes the removing owner's own wrap for the removed member, at
keys/{owner_pubkey}/{revokee_pubkey}.enc— a wrap another owner sealed for the revokee earlier holds a pre-rotation generation, so leaving it in place is harmless; that owner reclaims the slot when it next rotates.
After this, the removed member is no longer a current member (anything they sign is rejected against the chain), and everything sealed after the rotation is under a generation they never receive. Remaining members keep the old generations in their keyring, so data sealed before the rotation stays readable. This is why the timestamp does not need to be load-bearing: even a changeset with a timestamp from before the removal cannot be admitted, because it would be signed by a non-member. remove_member refuses to remove the last owner.
Removal does not retract old changesets the member already authored; pull stops admitting new ones.
Invite and join
An invite has to move two different things safely: the joiner's identity to the owner (so the keyring can be wrapped to it), and cloud access plus the wrapped keyring back to the joiner. A two-step handshake moves each in the right direction, and neither side ever types a key by hand.
The joiner runs generate_join_request, which mints a fresh Ed25519 keypair — scoped to this one join, not yet to any store — and produces a base64url code carrying its public key (and, for folder-sharing providers, the account email the owner should share to). They send it to the owner out of band.
The owner calls handle.invite_member(...) with that public key and a role. coven:
- grants the joiner cloud access,
- wraps the store keyring to their X25519 key,
- signs and validates the membership change against the committed state,
- activates the change through the owner's causal head or the Serial global head.
The cloud connection details come back packed with the store id, name, owner pubkey, wrapped-key author, exact Store root, and policy-shaped membership floor into an InviteCode. The owner sends that back.
The joiner constructs DeviceJoinClient from the invite and the join-request code it kept. The owner creates an offer with CovenHandle::begin_device_join. Four exact values then cross between them:
- The offer becomes a provider access request; the selected provider administrator returns an approval.
- The approval becomes a registration request; the owner accepts it and returns the provider-ready bootstrap.
- The joining device installs the snapshot database and returns its readiness proof; the owner verifies provider admission and returns an activation.
- The joining device installs that activation, atomically saves its config, moves the pending journal into the Store database, and promotes the pending identity into this store's custody.
Every client call resumes the durable journal. The same exchange admits a new member's device and another device belonging to an existing member.
The join call also receives the host's expected WritePolicy; a mismatch is refused before provider access or local writes. A custom S3 Serial join must add Some(CustomS3Serial::ConditionalPutAndStrongReads) so the local operator, not the invite, asserts that endpoint's conditional-write and read behavior.
The device is now a writer. A join that fails partway never deletes a store that already existed on the device, and leaves the pending identity from step 1 untouched — the same join can be retried with the same request code.
The invite code carries plaintext cloud credentials (for S3, the access key and secret). Treat it with the same secrecy as the encryption key, and send it over a private channel.
Restore codes
A restore code recovers a store on a new device of an existing member, without anyone re-inviting them. Where an invite code adds a new identity to the chain, a restore code re-establishes an identity that is already in it.
handle.generate_restore_code() encodes everything needed to reconnect into one coven:-prefixed base64url string: the store id, store_root_hash, store keyring, Ed25519 signing key, cloud provider, and that provider's connection details. The RestoreCode is plain JSON under that prefix.
coven:eyJ2IjozLCJzaWQiOiI1NTBl…Restoring with the signing key keeps the same Ed25519 identity, so the recovered device is still the same member in the chain and can keep writing. That identity is scoped to the one store the code names — a restore code for store A carries no authority in any other store the same device belongs to. decode_restore_code parses the string back, and on garbled input returns a RestoreCodeError (missing prefix, truncated base64, malformed JSON, or a version made by a newer build) whose Display text the host can show verbatim.
Restore likewise requires the expected WritePolicy and the custom-S3 Serial assertion when applicable; a code cannot silently select either on the host's behalf.
A restore code deliberately omits OAuth tokens, since those expire; on a consumer cloud the user re-authenticates during restore. Because the code contains the store keyring and any stored credentials, it is the most sensitive string coven produces; anyone holding it has full access to the store.