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.
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.
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.
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.
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.
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.
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:
pixie:kt-chain-genesis:v2 ‖ handle ‖ founder-ed ‖ founder-mldsa (the ‖ marks mean those
pieces are joined end to end before signing), and the founding device must
appear in its own roster.prevRoster.contains(authorPair): the author's full hybrid key
pair must already have been in the previous entry's roster. Only a device already on the roster
can extend it.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.
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.
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.
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.
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.
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.
author ∈ prev-roster rule (∈ means "is a member of") implements, and
which this system's architecture
notes name directly as the model being followed.