Pixieby Sociofabric

Joining and Leaving

Sync runs over a set of machines ("the account's other devices"), and Staying in Sync never said where that set comes from. It comes from a server. That is an uncomfortable answer in a system built so that no server can read anything. The resolution is not to remove the server. It is to make the server's answer checkable by the devices themselves. Then comes the harder direction: taking a device back out, where a mark in a database meets a machine that already holds a complete copy of your life.

Prerequisites: GroupThink for the sealed (end-to-end encrypted) same-account channel these devices talk over, and Contact Discovery for how an account comes to have a verifiable identity at all.


1. The set that sync assumed

Read Staying in Sync again with one question in mind: to whom? Advertise a digest (a short fingerprint of what a device currently holds) to whom. Notice a difference: with whom. Pull the rows you are missing: from whom. Every step in that loop is a verb with a missing object, and the object is always the same phrase: the account's other devices. The loop is a precise machine bolted to a set it never defines.

The obvious instinct is that the devices should work the set out among themselves. They already talk; let them gossip. That fails immediately at the case that matters most. A device that has just been unboxed holds nothing: no public keys for its siblings (the other devices on the same account), no addresses, no shared secret, nothing to gossip to. Peer-to-peer discovery has to start somewhere, and "somewhere" is precisely what a new device lacks.

It fails a second time even for established devices, and the second failure is subtler. Membership is a claim about absence as much as presence. Your iPhone believes the account has two devices. Is that because there are two, or because it has not yet heard about the third? Nothing local distinguishes those. Gossip converges (settles on one shared answer) only if you can put a boundary around the set of gossipers. And the set of gossipers is exactly the question you were trying to answer. So there is a rendezvous point, and it is a server.

The cost of getting this wrong is quiet rather than loud. A device whose roster (its list of sibling devices) reads empty does not error. It sends nothing, because the emitter of sync deltas (the small change records siblings exchange) quits early when the sibling set is empty. It behaves like a fleet of one, forever, with a perfectly healthy sync loop running against nobody.


2. Where the roster actually lives

The rendezvous point is identity-svc, and it is a deliberately boring service: one table, identity_records, one row per device.

The row holds a short list of fields. First, the device's 32-byte Ed25519 envelope pubkey (its public key under Ed25519, the classical digital-signature scheme), which doubles as the row's primary key. Next, its 1952-byte ML-DSA-65 envelope pubkey; ML-DSA-65 is the post-quantum signature scheme, built to withstand quantum computers, and section 5 has every device sign under both schemes at once. Then its libsignal identity key and registration id: libsignal is the encryption library behind Signal-style messaging, and these are the public material a stranger needs to open an encrypted session with the device. Finally a created_at timestamp and (added later, by database migration 0003) a user_id linking the row to an auth-svc account.

That last column is membership. There is no roster table; a roster is just a query: select every row whose user_id is yours.

Reading the roster is one endpoint, GET /v1/sync/siblings, and the shape of its handler carries most of the design:

SELECT pubkey, mldsa_envelope_pubkey FROM identity_records
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC

Three things about that $1, the query's one input. It comes from the caller's login session (never from a URL path or a request body the caller could set), so a caller can list only its own devices. The query keeps only rows where revoked_at is still empty, which is section 7's subject. And the ordering is newest-registered first, which the driver's-seat election in Who Acts reverses to make the oldest device primary.

Now the contrast that makes the design legible. There is a reverse lookup: get_user_pubkeys, readable by anyone, returning "every envelope pubkey registered to a user_id". And ordinary 1:1 messaging never calls it. ComposeService.recipientDeviceSet reads only local storage, and says so in a comment: never call IdentityClient.pubkeys(forUserID:). Asking the server for a friend's device list would let a sender enumerate a target's devices, and it would break sealed-sender unlinkability, the guarantee that the relay never learns who is talking to whom.

A friend's device set is learned only over the encrypted channel, from the friend. So the system answers two similar-sounding questions in two completely different ways: which devices are mine comes from the server; which devices are yours never does.


3. Two authorities, one account

Here is the tension this article exists to develop.

The roster is server-authoritative: the server's answer is the answer. identity-svc holds the list, and a device cannot talk its way back into an account by asserting things, because assertion is not how the list is written. The content of the account is device-authoritative. Every sync_delta body is sealed end-to-end to a specific sibling. The relay carries ciphertext (encrypted bytes) that it cannot attribute to anyone, and identity-svc, the one service that does hold readable data, holds only public key material and an account id. Nothing in either service can read a message, a note, or a memory.

MEMBERSHIP — SERVER-AUTHORITATIVE IDENTITY-SVC PUBKEY A · USER_ID 7 PUBKEY B · USER_ID 7 PUBKEY C · USER_ID 7 IDENTITY_RECORDS — ONE ROW PER DEVICE GET /V1/SYNC/SIBLINGS USER_ID FROM SESSION ONLY NEVER A FRIEND'S DEVICES CONTENT — DEVICE-AUTHORITATIVE DEVICE A DEVICE B RELAY SYNC_DELTA — SEALED END-TO-END TO ONE SIBLING THE RELAY CARRIES CIPHERTEXT IT CANNOT ATTRIBUTE TRUSTED FOR THE LIST BECAUSE TRUSTED FOR NOTHING ELSE

Those two facts have to coexist, and a reader is right to be uneasy about the first. Why hand a server any authority in a system whose premise is that servers are not trusted?

Because of how little the answer is worth to whoever takes it. The roster is a grouping of public keys under an account identifier, which is what a directory is for, and roughly what identity-svc learns anyway from the registrations it processes. Everything the roster points at is unreadable. Put the other way round: the server is trusted for the membership list precisely because it is trusted for nothing else. The design's job is to keep that trust narrow, not to eliminate it and quietly reintroduce it as a "sync server."

And the split has a matching rule on the device side, which is what stops server authority from becoming server power. SyncService honours a sync_request or a sync_offer only when the sender's certificate inside the sealed envelope checks out: its verified SenderCert.user_id_hash, a fingerprint of the sender's account, must equal the device's own. The server can tell your iPad that some pubkey is its sibling. It cannot make your iPad accept a snapshot from it. Membership and admission to state are two gates, and the server holds one of them.


4. Why the list cannot be believed on its own

The narrow trust still has an edge, and it is worth walking straight into it. register_identity takes the user_id it writes from the caller's session. So anyone holding a session for your account can put a row in your roster. And so, trivially, can whoever operates the database.

The first defence against that is the cross-signature. Every roster a device advertises to a contact carries the account's self-signing pubkey plus one DeviceCrossSig per device: a statement, signed with the account's own key, that device_pubkey belongs to the account identified by user_id_hash. Each one is valid for about a year.

A contact pins that account key the first time you talk: remembers it, and refuses to accept a different one later. From then on it accepts only devices carrying a valid cross-signature under the pinned root key. An update with no signature, or one signed under a different root, is not merged; it is rejected outright as a downgrade, an attempt to walk the security back. InboxService.verifiedDeviceSet returns nil for those, and the caller advertises nothing. That closes off roster poisoning by a lying relay.

It does not close account-key theft, and the reason is a counting argument. The account signing key is one keypair per account, minted by the founding device and replicated to every sibling. The rule is: mint the key if and only if you are the account's sole device; otherwise adopt a copy from a device that already holds it, over the sealed channel. So the key lives on N devices, and N grows every time you add one.

Anything the account key alone can authorize is therefore authorized by the weakest device you own. If holding that key is enough to say "this device is mine," a thief who gets it can say the same sentence. The thief signs a cross-signature for a device you have never seen, and your contacts fan every message out to it: send it a copy of everything, exactly as they would to a real device of yours.


5. A device is admitted by a device

The fix is to change who is allowed to say it, and the mechanism is the device-authorization chain: a list of roster states that is append-only (entries are added, never edited or removed) and hash-linked (each entry carries a fingerprint of the one before it, so history cannot be quietly rewritten), each entry signed by a device rather than by the account.

KTPublishLedger.verifyChain is the whole rule set, and it is short. Entries must arrive in contiguous version order starting at 1, all under the same account handle. Each entry's device signature must verify, and the signature is hybrid: Ed25519 (classical) and ML-DSA-65 (post-quantum), both halves valid or the entry is rejected. The preimage (the exact byte string that gets signed) binds both of the author's pubkeys. Each entry's prevEntryHash must equal the previous entry's hash. Each entry carries the actual roster and must hash to the rosterDigest it commits, with no duplicate devices. And then the two cases:

Follow that chain backwards, step by step: the argument mathematicians call induction. A device in roster N was put there by a device that was already in roster N−1; that one by a device in roster N−2; and so on down to version 1, whose author the account key blessed.

Therefore extending a roster requires an enrolled device's secret, not merely the account key. That is the property the chain buys, and it is exactly the property section 4 showed the cross-signature could not deliver: the thief now needs to compromise one of your actual devices, not a key that every device already carries.

ACCOUNT KEY — BLESSES GENESIS ONLY V1 · GENESIS A SIGNED BY A PREV HASH V2 A B SIGNED BY A PREV HASH V3 A B C SIGNED BY B EACH AUTHOR ALREADY IN THE PREVIOUS ROSTER EVERY SIGNATURE HYBRID — ED25519 + ML-DSA-65 NEWLY ADMITTED DEVICE PUBLIC LOG SEES ONLY THE 32-BYTE CHAINHEAD EXTENDING THE ROSTER TAKES AN ENROLLED DEVICE'S SECRET

Two details make the mechanism affordable rather than merely correct.

The log commits a hash, not a roster. The chain travels end-to-end encrypted inside the sealed DeviceList.auth_chain blob, and the code that decodes it lives entirely on the client: the relay and the backend never parse it. What goes into the public transparency log (an append-only public record anyone can audit) is the 32-byte chainHead, folded into a log entry (a leaf) beside the account's two signing pubkeys. A server watching the log sees a hash change when your roster changes, and learns nothing about what changed or how many devices you own. (What that log is, and why an append-only commitment is worth anything, is Algorithm: The Transparency Log.)

A contact binds the tip, not just the chain. verifyContactChain requires two things at once. The chain must verify under the pinned account root. And the tip (the newest entry) must carry a version equal to the log's latest version for that handle, with that version's committed leaf equal to (pinnedSigning, pinnedMldsa, entryHash(tip)). The freshness clause does real work. A chain that is genuine but truncated (cut off early, its newest entries withheld) still verifies perfectly, and would roll a contact back to an older roster, one that might still contain a device you removed. Requiring tip.version == latest refuses that. It also disposes of the stolen-key genesis attack. A thief can mint their own version-1 chain, but version 1 is not the latest version of an established account, so the bind fails. And if the thief instead publishes a new version to the log, the publication is public: the owner's self-monitor (the account's own watch on the log) sees a version its ledger did not author. The thief gets neither a stealth roster nor a silent one.

At the send path the consequence is short and sharp. Once a contact has a bound roster, recipientDeviceSet honours it verbatim, even if it is empty. The usual "fall back to the primary pubkey" rescue applies only to contacts with no bound roster, because rescuing would mean sending to a device the logged chain may not authorize.


6. Absorbing: the fleet reaches out

With membership defined, the two directions of change become concrete, and they are not symmetrical. Start with the easier one.

The instinctive shape for adding a device is: the new device proves who it is to a server, and the server lets it in. Under the split in section 3 that is backwards, because the server has nothing to let it in to. Admitting a device to the roster is not admitting it to the account; the account is the state, and the state is on the devices. So the operation splits in two.

Authorization. The new device is pre-session: it holds no bearer token (the credential a logged-in client shows with each request) and no keys anyone knows. It resolves its own account's discovery inbox (the relay address published in a directory so that people who want to reach you can; an address, not a secret), then seals a SignInRequest and posts it there. Online siblings receive it and acknowledge. The acknowledgement carries the sibling's make and model, so the new device can display Approve on your iPhone 17 Pro rather than a blank wait. When the user approves on that other device, it sends back a sealed SignInApproval carrying an enrollment grant, which the new device redeems through the ordinary register-with-grant path with revoke_others: false. Authenticity rests on the phone-ownership certificate the new device earned by passing SMS verification. A sibling checks two things: the certificate's signature against the pinned phone-verify key, and that its phone_id matches the sibling's own cached one. So a contact who merely knows your discovery inbox cannot raise a sign-in prompt on your phone. And if the key is not pinned, the check fails closed: no prompt at all, rather than an unverified one.

Notice the direction of travel: the existing devices reach out and take the new one in. There is also a plainer path (a signed-in device mints a three-minute grant with a six-digit code you type into the new one), but even there the grant originates from a member, not from a password.

State. Once enrolled, the new device asks a sibling for everything: a sync_request, a sealed sync_offer naming a blob and its key, a fetch, a SHA-256 check (a cryptographic-hash comparison proving the bytes arrived intact), a decrypt, a wholesale import. It does not replay the account's history. Why a snapshot rather than a replay, and why the two halves above look alike and are not, is Mechanism: Absorbing a New Device.


7. Expelling is not the mirror image

Removal looks like addition with the sign flipped. It is not, and the asymmetry is structural (built into the problem) rather than an artefact of this implementation.

Adding is constructive: to become a member you must acquire something you do not have (a grant, a snapshot, a chain link), and each requires cooperation from someone who already is a member. A failed add is a non-event; nothing happened.

Removing is destructive: it must take away things the target already holds. Its keys. Its bearer token. Its full replica, its complete local copy of the account. Its ability to keep asking questions. Writing a value into a database column does none of those. What it changes is what other parties learn when they ask. So the strength of a removal is exactly the number of places that ask, and that is an open set that grows with the system.

What exists today is one column, two readers, and one operation that is not a server action at all. remove_sibling (session-authed, scoped to your own account) runs an UPDATE on identity_records: it sets revoked_at = now() on the row matching the caller's account and the named pubkey, and only where revoked_at IS NULL. Running it twice is the same as running it once: idempotent, in the jargon. revoke_all_siblings does the same across the whole account, which is what account recreation uses while it still holds the old session.

Two lookups honour the flag: get_siblings and get_user_pubkeys. The by-pubkey lookups do not. get_identity and fetch_bundle will still serve a revoked device's row and its prekey bundle: the public keys a stranger fetches to start an encrypted session with a device that may be asleep.

Alongside the column, the removing device rotates the account's shared discovery identity: a fresh sealing key and a fresh inbox, republished and handed to the siblings that remain. Why rotate? That secret is fleet-wide, so the removed machine holds a copy, and no server-side revocation can reach a key already sitting on a machine you no longer control. The only move available is to stop using the key and the address it opens (Rotating the Key Strangers Seal To).

Why mark the row instead of deleting it? Migration 0011 answers, and the answer is worth tracing because it is the same lesson deletion teaches everywhere in this series. register_identity inserts with ON CONFLICT (pubkey) DO NOTHING (if a row with that pubkey already exists, the insert quietly does nothing) and reports the collision to the caller as a 409, the HTTP status code for "conflict."

Delete the row, and the removed device re-registers the same pubkey and is silently back in the account. Keep the row and mark it, and re-registration collides with a record that is still there. The retained row is a tombstone: absence carries no evidence, so you store the removal instead of storing nothing. It is also what lets the removed device find out, which brings us to the uncomfortable part.

Departure is cooperative: the removed device has to ask about its own removal. self_status is a public read keyed by the device's own pubkey; the handler's comment explains why that is safe: the pubkey is the identifier, and revocation status is not sensitive. checkSelfRevoked() polls it on the same cadence as sibling sync, and a revoked: true triggers resetForRecreate() and flips the onboarding gate so the app returns to sign-in. But nothing compels the poll.

And at the contact edge, section 2's privacy property becomes revocation's cost. Your friend's app never asks a server which devices you own. So nothing a server does can remove a device from your friend's fan-out; only a fresh signed roster and a new chain version, advertised over the encrypted channel and re-bound against the log, will. The design that refuses to let anyone enumerate your devices is the same design that cannot broadcast a removal. Mechanism: Revoking a Device takes that apart properly.


8. Revoking a future, never a past

State this one plainly, because it is the claim most often fudged.

The departing device holds a complete replica. That is not an oversight; it is the entire purpose of the machinery in Staying in Sync. Your messages, your notebook, your memory log, your contacts: all of it was deliberately placed on that machine, and no update to a column on a server un-reads it.

What removal actually buys is a list, and the list is worth being exact about. The fleet stops addressing the device, because it drops out of get_siblings and therefore receives no further deltas. It cannot return under the same identity, because the tombstoned row refuses the re-registration.

It loses the account's discovery identity: key and inbox both move, so it can no longer open an inbound hello or a sign-in request, whatever it chooses to poll. And if it cooperates, resetForRecreate() wipes its keychain identity and deletes its messages, posts, calls, outbox, libsignal session blobs and per-row causal clocks (the version counters sync uses to order edits).

Now read that last item for what is not in it. The code's own comment is "Drop identity-tied local data; keep Contacts + agent memory." A cooperative self-wipe leaves the contact list and everything the agent has learned about your life. And it is the only item on that list that needs the removed device's cooperation: the roster drop, the re-registration refusal and the discovery rotation all happen elsewhere, on machines you still control.

So: removal revokes a device's future (future deltas, future registration, future fan-out once your contacts rebind) and gives you nothing whatsoever about its past. Any system that tells you otherwise about hardware in someone else's hands is describing a wish.

ITS FUTURE — REVOKED SIBLINGS GONE FROM GET_SIBLINGS CONTACT ONCE CONTACTS REBIND — FAN-OUT ENDS IDENTITY_RECORDS — TOMBSTONE PUBKEY · REVOKED_AT RE-REGISTER SAME PUBKEY → 409 FLEET EDGE REMOVED DEVICE ITS PAST — INTACT MESSAGES · NOTEBOOK · MEMORY · CONTACTS REMOVAL REVOKES A FUTURE — NEVER A PAST

9. What membership does not give you

Four limits, stated here rather than left for a reader to find.

Sessions are account-scoped, not device-bound. auth-svc's sessions table is (session_token, user_id, issued_at, expires_at), with no device column, and identity-svc's AuthContext carries a user_id and nothing else. A handler therefore cannot ask whether the calling device is revoked, because it does not know which device is calling. Removing a device does not invalidate its bearer token. And since remove_sibling is scoped by account rather than by caller, a revoked device that still holds a live session can turn around and remove the device that removed it. This is material for Mechanism: Revoking a Device; it belongs here because it is a property of the split itself, not of that article's mechanism.

The chain ratifies the server's answer; it does not replace it. The roster a device signs into the chain is assembled from identityClient.siblingDevices() (the server's list) plus its own pair. So the chain proves an enrolled device signed off on a roster state. It does not prove a human approved each member. What gates enrollment is the approval flow in section 6; what detects an unapproved member is the owner's own self-monitor watching the log.

The chain only ever tightens. A contact who has never bound your chain stays on cross-signature trust. An unverifiable or not-yet-logged chain leaves the previously authorized roster untouched rather than clearing it. That is deliberate (a lag in publishing must not break message delivery), but it means the strong property of section 5 holds for contacts who have bound your chain, not for all of them, and a reader should not round that up.

Publishing the roster is single-writer. Exactly one device advances the chain's version, gated on the driver's seat, because two devices publishing at once would collide over the same version number. Which device that is, and what happens if it disappears mid-flight, is the question Who Acts opens.


References & further reading

← Staying in SyncWho Acts →