Adding a device to an account is a decision about the future, and the account gets to make it. Removing one is a claim about a machine that is no longer yours to instruct. And a mark in a database column is not an instruction. What exists today is a server-authoritative flag plus a cooperative self-logout, which does the whole job for the ordinary cases and leaves a specific, checkable set of things open. This is published before that gap closes, on the view that naming an edge precisely is worth more than waiting to describe it in the past tense.
Prerequisites: Joining and Leaving for the roster this operates on and why it is server-authoritative, and Mechanism: Absorbing a New Device for the direction that works cleanly.
Joining and Leaving ended on an asymmetry worth restating, because everything below is a consequence of it.
Adding a device is constructive. To become a member, a machine must acquire things it does not have: an enrollment grant minted by a device already in the account; a snapshot exported by a sibling, another device on the same account; a link in the device-authorization chain (the signed record of which devices the account has admitted) signed by a device that was already in the previous roster, the account's device list as it then stood. Every step needs cooperation from someone who is already inside. A failed add is a non-event: nothing happened, and nothing has to be undone.
Removing a device is destructive, and destructive operations have targets.
The target here already holds a complete replica (a full local copy, in its on-device SwiftData store) of your messages, posts, calls, notebook and memory log. It also holds the libsignal double-ratchet sessions for every conversation you have (libsignal is Signal's encryption library): the per-conversation key state that steps forward with each message, and the thing that actually decrypts the traffic.
It also holds the account signing seed (the secret a sign-in approval hands to every enrolled
device) from which
InnerMAC.siblingChannelKey(accountSeed:) derives the channel key that defines the
same-account sibling channel. And it holds a bearer token (a credential granting access to
whoever presents it) that auth-svc issued for 30 days
(Utc::now() + chrono::Duration::days(30)) and has no code path to take back.
None of that is on a server. UPDATE identity_records SET revoked_at = now() reaches
none of it.
What a removal can actually change is what other parties answer when they are asked. So the strength of a removal is exactly the number of places that ask, times how promptly they ask, and that is an open set which grows every time someone adds a feature.
One piece of housekeeping before the walkthrough. The mechanism in sections 2 and 3 is shipped and works. The gaps in sections 4 through 7 are open in the code as of writing, and section 8 is work owed rather than a description of something built.
The whole server-side mechanism is migration 0011_device_revocation.sql (a migration being a
versioned change to the database schema), and it is one
line:
ALTER TABLE identity_records ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
The interesting content is the comment above it, which states three reasons for marking the row rather than deleting it: the device (a) drops out of the sibling list, (b) cannot be silently resurrected by re-registering the same pubkey (its public key, the shareable half of its identity keypair) and (c) can self-detect and re-onboard with a fresh identity.
Reason (b) is the one worth deriving, because a reader's first instinct is that a removed device
should have its row deleted. Follow what happens if it does. register_identity inserts with
ON CONFLICT (pubkey) DO NOTHING and reports the conflict as a 409 (HTTP's "conflict"
status), taking the user_id from
the caller's session.
Delete the row, and the removed device, which still holds that keypair, re-registers the same pubkey. The insert succeeds because there is nothing to conflict with, and the device is back on the roster with no trace that it ever left. Keep the row and mark it, and the re-registration collides with a record that is still there.
That is the same lesson as Deleting Things That Stay Deleted, in a completely different subsystem: absence carries no evidence, so you store the removal instead of storing nothing. The retained row is a tombstone (a marker that records a removal), and reason (c) is only possible because the tombstone survives: a removed device asking "am I still in the account?" needs a row to read the answer off.
Two handlers write the column. Both are session-authed (the caller must present a valid login session), and both are idempotent, so running one twice changes nothing more than once:
// remove_sibling
UPDATE identity_records SET revoked_at = now()
WHERE user_id = $1 AND pubkey = $2 AND revoked_at IS NULL
$1 comes from the session, never from the request body, so a caller can only revoke devices on
its own account. The handler returns 204 No Content whatever the row count, so the response
never distinguishes "removed it" from "that pubkey was not yours": the affected count goes to
the log, not to the caller.
revoke_all_siblings is the same statement without the pubkey clause, used on account
recreation: the recreating device calls it while it still holds the old session, so every
device on the abandoned account is marked before the new one is created.
Alongside that column there is an operation that is not a server flag at all, and it is the only step in a removal that takes a live capability away rather than recording an intention.
The account's discovery identity (the key strangers seal a first hello to, and the inbox they post that hello to) is one identity per account, replicated to every device. That is what makes approve-on-device work from whichever handset you happen to be holding. It also means the removed machine has a copy, and no server can reach into its Keychain (the phone's secure credential store) and take it back.
So removing a device mandatorily rotates that identity: a fresh sealing key and a fresh inbox, published to the directory, with the new epoch set (the rotation's new generation of key and address) handed to the siblings that remain. Both halves have to move. Rotating only the key would stop the removed device reading new hellos while leaving it able to subscribe to the address they arrive at. And a subscriber that receives and discards is a subscriber that denies service.
The full argument, including why the grace window exists and what the rotation does not undo, is Rotating the Key Strangers Seal To.
The rotation needs nothing from the removed device, which makes it the exception to everything section 4 is about to say. It is also loud on failure: if it does not complete, the log says so explicitly, because the alternative is a user believing a revocation happened that did not.
A tombstone nobody reads is a comment. The read is GET /v1/sync/self-status/:pubkey_hex,
and its two design choices are both defensible.
It is public: no session. The handler's comment gives the reasoning: the pubkey is
the identifier, and revocation status is not sensitive. A device that has been removed may
well have a session that is broken or absent, so requiring one would make the endpoint
useless in exactly the case it exists for. The response is two booleans:
{ "registered": bool, "revoked": bool }, where an absent row answers
registered: false (a hard delete, as opposed to a revocation).
It is polled by the subject, not pushed to it. checkSelfRevoked() in SocialServices
runs at the end of bootstrap() and again from onForeground(), guarded on
currentSession != nil, isRegistered so only a fully-enrolled device asks. On
status.revoked, three things happen in order: resetForRecreate() runs; then
userDefaults.set(false, forKey: "pixie.onboarded") clears the flag ContentView reads;
so the UI swaps to the onboarding flow on the next render.
resetForRecreate() is worth reading for what it keeps as much as what it drops. It wipes the
device identity keys (preserving the phone-ownership certificate, which is bound to the phone
number rather than the account), clears the saved session and the registration flags, and
deletes MessageRecord, PostRecord, CallRecord, OutboxItem, LibSignalBlob and
RowVersion.
Its own comment states the scope: drop identity-tied local data; keep Contacts + agent memory. Because the keys are wiped, re-onboarding mints a fresh envelope keypair, which registers without colliding with the tombstoned one, closing the loop opened in section 2.
For the cases most people mean when they say "remove a device", this is the entire job. A phone you sold will run the check on its next launch under the new owner and take itself out. A laptop you left at a friend's house does the same. Account recreation revokes the whole roster in one call so no orphan lingers. None of these situations contain an adversary; the mechanism's job is to make the honest case converge, and it does.
The word doing the work in section 3 is cooperative. Two conditions have to hold, and
both live in code the owner no longer controls: the removed device has to poll, and it has
to honour the answer. Nothing outside the device enforces either. A build that never calls
checkSelfRevoked (a modified client, an older version, a process that never
foregrounds) keeps every capability it had.
The interesting consequence is not that such a device keeps working. It is what the fleet's own filter does to it. Watch the driver's-seat election (the vote that picks which single device runs the account's autonomous agent work) from both sides.
The remaining devices ask GET /v1/sync/siblings, which filters revoked_at IS NULL, so
the removed pubkey is gone from rankedOldestFirst. The election in
Who Acts is index-based over that list, so the removed device stops being
"higher priority" than anyone, and the fleet re-elects a driver among the devices that
remain. Exactly right.
Now the removed device runs the same code. Its siblings() call returns the account's
remaining devices and, because its own row is revoked, not itself. reconcileSeat opens
with:
guard let myIdx = rankedOldestFirst.firstIndex(of: myPubkey) else {
holdsSeat = true
yielding = false
return
}
Unknown to the roster means act solo, so the agent on a brand-new or offline device never stalls. It is a good default that produces the wrong outcome here: the removed device concludes it is the sole device on the account and takes the seat. The fleet does not split into "one driver and one ex-device": it splits into two devices that each believe they are driving, one of which is holding a full copy of your life and running autonomous work against it.
One more degradation belongs in this section, because it weakens even the cooperative path.
siblingRoster() falls back to the cached selfAdvertisedDeviceSet when the live fetch
fails. That is deliberate: a transient outage degrades to "use what we knew" rather than
"pretend we're alone". The cache still contains the removed device. So a remaining sibling
that cannot reach identity-svc keeps sending sync_delta frames to a device it would
otherwise have dropped, until its next successful fetch.
Here is the structural fact underneath all of it, and it is short enough to verify yourself. Every backend service that defines an authentication context defines the same struct:
pub struct AuthContext {
pub user_id: Uuid,
}
identity-svc's require_session middleware (the check that runs before every handler) pulls
the bearer token, asks auth-svc to validate
it, and inserts that struct into the request extensions. auth-svc's sessions table is
(session_token, user_id, issued_at, expires_at): there is no device column, nothing
deletes rows from it, and the revoke_others flag on the recovery flow deletes credentials
(passkeys, the built-in cryptographic login credentials), not sessions.
So a handler cannot ask whether the calling device is revoked. Not "does not": cannot. The request carries an authenticated account, and the question is about a device. The information is not in the room. Four consequences follow directly, and each is checkable against one function:
A revoked device can remove the device that removed it. remove_sibling scopes by
auth.user_id, and a revoked device's session still resolves to that user id. The operation
is symmetric between a device that is in the account and one that is not.
A revoked device can re-enroll under a new pubkey. register_identity takes user_id
from the session and inserts. The tombstone refuses the same pubkey; nothing refuses a
freshly minted one. This is at least not silent (the new row appears in the account's
device list, which is the owner's detection surface), but the tombstone stops a resurrection,
not a re-entry.
credential-svc keeps minting sender certificates. A sender certificate is the credential
that rides inside a sealed envelope to tell the recipient which account sent it, the outside
carrying no return address. post_sender_cert has a revocation
check, and it is the wrong granularity: is_user_revoked looks the caller up in
user_revocations, which trust-svc writes to ban an entire account. There is no device
term. A revoked device with a live session mints a certificate binding its own
sender_pubkey to the account's user_id_hash. relay-svc (the server that forwards sealed
envelopes between devices) then gates admission on the certificate's TTL (its time-to-live), a
rate-limit token (a spend-once credential proving the sender has budget left), and a certificate
revocation list keyed on cert_id: nothing that could
notice.
And with that certificate, it can ask a sibling for a fresh snapshot.
SyncService.handleRequest gates on one predicate, isSameAccount(senderUserIDHash), then
exports the whole payload, seals it, uploads it, and offers it back to the replyPubkey
named in the request. The same-account gate is the right one for the threat it was built
against (a stranger impersonating a sibling); it does not distinguish a sibling from a
former sibling. This is the consequence that undercuts the comfortable framing. Revocation is
supposed to give you the device's future. Until this gate learns about the roster, a
removed device can pull a current copy of everything, not merely keep the stale one it
walked out with.
identity-svc answers two superficially similar questions in two very different ways.
get_siblings is auth-bound: which devices are mine, derived from the session, filtering
revoked_at IS NULL. get_user_pubkeys is a public reverse lookup: which devices belong to
this account id. It exists because group fan-out (delivering one message by sending a
separately sealed copy to every member device) needs it: group-svc returns a group's
membership as account user_ids, and the sender resolves each to device pubkeys so it can
ship one sealed 1:1 envelope per device.
For a long time, one of these filtered revoked_at and the other did not. They sit forty
lines apart in the same file. The consequence was precise: a device removed in Settings
dropped out of its own account's sibling traffic and kept receiving group traffic, sent by
people whose devices had made the server call that returns the removed pubkey, and had no
reason to question the answer. The revocation held for the owner's own fleet and silently did
not hold for anybody else's sends.
That filter has since landed, and the source comment above get_user_pubkeys now records the
history rather than hiding it: this is a fan-out source: every pubkey it returns gets a sealed
envelope. The pair is more instructive than either query alone, because the fix was applied to
one and not the other for reasons no design document contains.
get_siblings was written as part of the revocation feature and the flag was on the author's
mind; get_user_pubkeys predated it and belonged to a different subsystem. A check that must be
repeated at every call site will be missed at whichever call site the author is not looking at,
and the miss is invisible because both queries return a plausible answer.
The class is not closed by that one fix. Three other public readers still serve a revoked
device's row: get_identity, get_user_id (which subscription-svc resolves through for the
relay entitlement check), and fetch_bundle, which hands out the prekey bundle a peer needs
to open a libsignal session (the public keys a stranger fetches to start an encrypted session
with a device that may be asleep), alongside claim_key_package, the same service for MLS
(Messaging Layer Security),
the group-messaging protocol group threads run on.
Any peer holding a removed device's pubkey can still establish a session with it.
Below all of this sits a limit no filter reaches. The relay's subscribe path takes a list of subjects (the opaque addresses frames are routed by) on the socket's query string and has no session at all. That is by design, since the relay must not know who is behind a subject.
Pair subjects are derived from (channelKey, devicePubkey, epoch), the epoch being a counter
that steps forward on each key rotation, and the removed device holds
every input, so nothing stops it subscribing. What stops delivery is that senders no longer
publish there. For a pair subject, revocation is enforced at senders and can only ever be
enforced at senders.
The one address that is not like this is the published discovery inbox, and it is instructive that the exception exists for an unrelated reason. Because that address is directory-derivable, the relay pins it to the first key that proves possession, so a stranger cannot squat on it (Who Gets to Listen).
But the claim key is derived from the discovery secret itself, which the removed device still holds, so it passes that gate too. Which is exactly why removal moves the address, as the rotation in section 2 describes: the pin cannot evict a device that legitimately knows the secret, so the account stops using the address the secret opens.
The contact edge is where the design's privacy commitments and its revocation story pull against each other, and it is worth being exact about the cost.
Your friend's app never asks a server which devices you own. recipientDeviceSet reads only
local storage, and PeerDevice's header comment says why: a server reverse-lookup by pubkey
would let any sender enumerate a target's devices and break
sealed-sender unlinkability, the guarantee that watching
envelopes does not reveal which devices belong to whom. So
your contacts learn your device set exactly one way: from you, in a sealed device_list
advertisement over the encrypted channel.
Three details decide how quickly a removal reaches them.
refreshSelfDeviceState recomputes the set as siblings() ∪ {self} (the sibling list plus this
device itself), caches it, and calls
pushDeviceListToContacts only when the CSV (the set serialised as one comma-separated
string) changed. It runs at bootstrap and on
transport resume, not from the removal handler. removeDevice posts to identity-svc and
refreshes the Settings list; the advertisement rides the next launch or foreground.
applyDeviceList upserts (inserts or updates) the advertised devices and tombstones the ones
the update dropped
(revoked = true, so fan-out stops), but never the primary pubkey, which is the threading
anchor for the conversation. If the device you removed is the one your friend originally
handshook (the one their app first exchanged keys with), that rule alone will not drop it.
chainAuthorizedFilter is what saves that case, for contacts who have one. A contact holding
a device-authorization chain that verified under your pinned
account root, and that was bound to the transparency log (the public, append-only log of key
bindings) intersects the fan-out set against the chain's
authorized roster. It honours an empty intersection rather than rescuing to the primary. A contact with no bound
chain (first contact, a legacy peer, or a publish that has not landed) keeps the full set,
primary included.
Three pieces of work, and they differ by an order of magnitude in cost. Naming which is which is the useful part.
Roster filtering at every fan-out site is cheap and never finished. It is one WHERE
clause per query, but it is a discipline rather than a mechanism, and section 6 is the
evidence. The structural version costs slightly more and holds: make the default read path
exclude revoked rows (a view, or a single accessor every handler goes through), so seeing a
revoked device requires opting in, and a new call site inherits the safe answer by
forgetting rather than by remembering.
A revocation check on the authenticated caller belongs in the middleware, and cannot go
there yet. require_session is exactly the right layer: one function, every write endpoint,
no per-handler discipline. Written today it would be four lines. It is unimplementable
because the middleware resolves a user and the question is about a device, which makes it
strictly dependent on the third item.
Device-bound sessions are a migration, and the only genuinely expensive piece. sessions
gains a device column; every path that issues a session (passkey assertion, grant redemption,
approve-on-device) has to carry the device pubkey and prove possession of it; otherwise a
caller could name someone else's device and the binding would be decorative. Every AuthContext struct gains a
field, and the services that read them ship together with auth-svc. Then
remove_sibling has to actually invalidate: a cross-service call that deletes the removed
device's session rows. That is a capability auth-svc does not have today, since nothing ever
deletes a session. Note one honest cost on the privacy side: binding sessions to devices puts
an account→device linkage in auth-svc. It is not a new fact in the system (identity-svc
holds precisely that mapping), but it is that fact in a second place, and this design usually
pays to avoid duplicating exactly this shape.
There is a fourth item that no amount of server work supplies. The sibling channel key is derived from the account signing seed, and that seed is replicated to every device in the account. Removing a device does not change it, so the removed device remains cryptographically inside the same-account channel: it can still derive its siblings' subjects, and its own.
A removal that genuinely changed the channel would have to rotate the account seed and have every remaining device adopt the new one, then publish a new chain version so contacts rebind. That is a rekey, and it is the same operation an MLS group performs on a removal commit. See Algorithm: Who Gets to Put You in a Group for what that buys and what it does not.
Suppose all of section 8 ships. Sessions are device-bound and die with the roster row, the middleware refuses a revoked caller, every fan-out site filters, and the account seed rotates on removal. What have you got?
You have stopped the device from receiving anything new, from asking for anything new, and from proving anything about itself. You have not touched the replica.
Every message, note, memory entry and contact that was on that disk is still there, in plaintext, and the reason it is there is not an oversight: it is the entire purpose of the machinery in Staying in Sync. A system whose premise is that your devices hold your life locally has, as its exact dual, the property that a device you eject leaves holding it.
So the guarantee is one-sided, and the honest phrasing is the one this series has used before: you can revoke a device's future; you can never revoke its past. The rekey in section 8 is not a way around this. It is the precise statement of it: rekeying is what makes traffic after the removal undecryptable to the removed member, and it says nothing at all about traffic before.
That property has a name: post-compromise security, the guarantee that after a rekey the party you excluded reads nothing new. It is the same boundary MLS draws when a Commit removes a member from a group: the removal commit rekeys the epoch, so the removed member's keys stop decrypting from that point forward, and every message they already read stays read.
Which suggests the measure to apply to any device-removal feature, including this one: not whether the device was removed, but which future traffic it is excluded from, by whom, and after how long a delay. Answer that and you have described the mechanism. Say "the device was removed" and you have described a database write.
revoked_at is a mark and not a delete, argued in the CRDT setting (CRDTs being the
conflict-free replicated data types the sync layer builds on).