GroupThink
GroupThink
24 min read
Your account is not a device. It is a set of them — each holding a complete copy of your messages, your Notebook and your agent's memory, and each equally entitled to change any of it. GroupThink is the machinery that keeps that set thinking as one: finding what drifted apart, agreeing whose edit survived, taking a new device in and putting an old one out, and deciding which device acts when only one may. No server holds the authoritative version, so no server can settle an argument between them — and every question that server would have answered has to be answered by the devices themselves. Those questions are what this series is about.
Prerequisites: none — this is the top of a series. The transport underneath belongs to the siblings: The Relay for what a sealed envelope guarantees, and Contact Discovery for how two people come to share a key at all. If you have read Agent, this is the pillar it kept pointing at.
1. An account is a set, not a device
Most apps that run on more than one screen quietly nominate one of them as real. The phone holds your data and the tablet is a window onto it; or the account lives in a datacentre and every device you own is a view. Either way there is one authoritative copy, and the other surfaces are projections of it.
Pixie makes the opposite choice, and it is the choice this entire series is downstream of. Every device on your account is a full replica. Your phone, your tablet and your desktop each hold the whole store — messages, contacts, Notebook, memory log, plans — and each may write to any of it, offline, without asking permission from another device or from a server. They are siblings: peers of equal standing, none of which is the original.
The fork is easiest to see at the moment an app grows a desktop version, because that is where the cheap answer is most tempting. A companion is a second surface that reads through the phone or through a cloud account holding the real data. Companions have no state of their own to be wrong about — and, in exchange, a hard dependency: either the phone must be awake and reachable, or a server must hold your plaintext so both ends can read it. Neither is available here, so the companion design is not so much rejected as unavailable.
Concretely, then: a Mac running Pixie registers its own row in the identity service, with its own device keypair — an Ed25519 envelope key and its ML-DSA-65 half — exactly like a phone does. GET /v1/sync/siblings returns the account's device pubkeys, with the account derived from the session rather than from anything in the request, so a caller can enumerate only its own fleet. The Mac holds a complete replica. Nothing about it is a projection of the phone — which is what The Homebase means when it calls the desktop a full peer rather than a companion.
Say what that buys before counting what it costs. Every device works at full capability with no network and no sibling awake. Losing one loses nothing. No machine you own is demoted to a terminal for another, and no server is ever in a position to be asked which version is right — because it does not have a version.
The bill arrives immediately, and there is no distinguished copy is the whole of it. With three devices you have three complete replicas and no tiebreaker. "Primary" does exist as a word here, but it means a rank in an election (§8), not a data authority. Every convergence problem in this series is the same bill, itemised.
2. The channel your devices share
Your phone and your Mac are almost never on the same network, and neither has a route to the other. They talk through the relay, which carries every byte of your fleet's coordination — a fact that would be disqualifying if it could read any of it.
It cannot, because same-account traffic reuses the contact machinery wholesale with one substitution. Every granted device holds a 32-byte account root secret, minted by the founding device while it is the account's only device and delivered to each later device through the sign-in approval. From it, InnerMAC.siblingChannelKey derives — by HKDF under a fixed info string — a sibling channel key shared by exactly your devices. That key plugs into the slots a contact's handshake-derived key plugs into:
- The envelope's routing address is a pair subject, mixing the channel key with the recipient device's pubkey and an epoch counter. No public key appears on the wire, and the subject rotates.
- The sender credential is minted per pair, so the relay gets an independent pseudonym per channel rather than one identifier clustering all of your traffic.
- The relay-visible type is a coarse wake class. A
sync_deltaframe collapses tosilent, the same class reactions and consent updates ride.
The frames carry a free-form kind and an opaque body — state_digest, reconcile_req, reconcile_resp, gc_propose, heartbeat — none of which the relay parses.
Now the part that makes the channel self-gated. On receipt, the device resolves which key should verify the frame's MAC by reading the verified sender certificate: ChannelKeys.resolve returns the sibling key if and only if the certificate's user_id_hash equals our own, and otherwise looks up the contact bound to that hash. The syncDelta dispatch then re-checks senderUserIDHash == myUserIDHash before applying anything.
Follow what an attacker from another account would need. To reach the subject, the sibling key. To carry a certificate claiming our account, a credential the credential service will not mint for them. To pass the MAC, the sibling key again — because the hash they claim is the very field that selects the key their message must verify under. The check is not a policy test on an attacker-controlled field; the field chooses its own judge, and no key resolved means the envelope is rejected.
3. The server we removed
Here is the constraint that makes the rest of this series interesting, stated as what we gave up.
A conventional multi-device system converges by having a trusted server hold the authoritative copy. That one decision quietly answers five separate questions. The server knows what each device last received, so it can say what changed. It serializes writes, so concurrent edits arrive in an order and conflicts have a winner by construction. It is told about deletions and remembers them, so a delete stays deleted. It holds the account record, so it knows who is in the account. And it can hand out a lease, so exactly one device runs the background job.
We do not have that server, and not by oversight. The standing invariant is that account identity, keys, and plaintext are never concentrated at one place. Even if we wanted the relay to arbitrate, it could not: it does not know that two envelopes on two rotating subjects belong to the same person, and cannot read either.
So the five questions come back, unanswered, and the devices have to answer them for themselves. That is the table of contents.
| The question a sync server would answer | Where the devices answer it |
|---|---|
| What diverged? | Staying in Sync → Reconciling by Digest |
| Whose edit wins? | Clocks You Cannot Trust |
| Did that delete really happen? | Deleting Things That Stay Deleted → The Convergence Barrier and the Cloud Anchor |
| Who is in the account? | Joining and Leaving → Absorbing a New Device, Revoking a Device |
| Who is acting? | Who Acts → The Driver's Seat |
4. What diverged?
Two devices that have been apart need to find their difference. The obvious method is to exchange inventories — you list what you have, I list what I have, we diff. Count the cost: a modest account holds tens of thousands of message rows, the check runs every time a device wakes or reconnects, and the overwhelmingly common answer is nothing changed. An O(n) transfer to learn nothing is the wrong shape.
So the cheap question is made answerable in constant space. State is partitioned into namespaces — thirteen of them at the time of writing (settings, messages, memory, notebook, saga, contacts, plans, peerDevices, fileIndex, ktIdentityLedger, blocks, posts, agentConversations) — each hashed into one 32-byte Merkle root. A whole StateDigest is a version byte plus one root per namespace: 417 bytes today, and a fixed size however much you own. Equal roots mean the devices are provably in sync with no data transferred; an unequal root says which namespace to descend into, and only that one.
The load-bearing part is not the tree, it is the leaf encoding contract. The bytes must be identical on every device and every app version, so the leaf commits only to content that is supposed to converge and excludes anything inherently per-device: when this device first saw a message, local row identifiers, a contact's link into the system address book, the outbox. Dates hash at second granularity so a float round-trip cannot manufacture a difference; optionals carry a presence byte so "absent" and "empty" never collide. Get one field wrong and two devices are permanently, silently "divergent" — reconciling forever over a difference that is not real.
Once a namespace is localized, the transfer is a pull: reconcile_req carrying range fingerprints over the requester's leaves, reconcile_resp carrying the rows that differ. Both ride sync_delta sub-kinds, so reconciliation needed no new wire type and inherits §2's sealing and self-gating for free. The pull is one-directional but run independently from both ends, which is how a pair converges with no leader — Staying in Sync, and Reconciling by Digest for the mechanics.
5. Whose edit wins?
You retitle a note on your phone at 9:02 and, offline, retitle it on your Mac at 9:03. Both edits are real. One has to lose.
The intuitive rule is newest-wins on a timestamp, and it is wrong in a way that is hard to see. Device clocks disagree. A phone running two minutes fast wins every contested edit against every other device, permanently, and there is no error to notice: the losing edit is not rejected, it is overwritten and gone.
The replacement is a version vector — a map from device identifier to a monotonic counter. A device bumps its own dimension when it edits a row; comparing two vectors answers whether one causally happened after the other or whether they are concurrent, with no wall clock involved anywhere. Merging is elementwise max, so vectors converge alongside the values they describe. The gain is not that conflicts stop happening; it is that a genuine conflict becomes a detectable state rather than an invisible one, at which point the tie can be broken deterministically — by the lexicographically greater encoded row — so both devices independently choose the same winner without a round trip.
Two details matter more than they look. The vector is not part of the content leaf, so this bookkeeping can never perturb the digest of §4. And only four namespaces are causal at all — notebook, saga, plans, contacts; the rest get the weakest rule that converges, which Clocks You Cannot Trust works through case by case.
6. How does a delete stay dead?
Deletion is the hard operation in a replica set, and the reason is almost philosophical: absence carries no evidence. Every other change leaves a trace you can ship. A delete leaves a gap, and a gap is indistinguishable from never having had the thing.
Play it out. You delete a note on your phone; your iPad was offline and never heard. On the next reconcile the iPad notices it holds a row the phone lacks — exactly the situation the protocol exists to repair — and helpfully ships it back. The deletion undoes itself, and the more reliable your sync is, the more reliably it undoes itself.
The fix is to make the deletion a thing rather than the absence of one. A tombstone records the row's key and the version vector at the moment of deletion, and travels by the same path as an ordinary row, so it needs no new machinery. The receiver deletes only when the tombstone causally dominates its own copy, so a concurrent live edit survives. The row's version record outlives the row it describes.
One consequence is worth noticing: because a tombstone's key sits in no live leaf, the responder has to ship its whole tombstone set rather than only the part covering a mismatched range — worked through in Deleting Things That Stay Deleted.
That leaves the bind the series treats as its hardest problem: tombstones cannot be kept forever, because they grow without limit, and cannot be dropped on a timer, because an offline device will resurrect the data. The answer is to collect garbage on evidence — a nonce'd barrier round (gc_propose / gc_ack / gc_commit) in which every sibling confirms it stands at the agreed digest — plus a monotonic generation anchored in iCloud Drive to catch what a barrier cannot: a device restored from an old backup, re-entering with pre-deletion state and no memory of the round. The Convergence Barrier and the Cloud Anchor is the deepest article here.
7. Who is in the account?
Everything above presumes the set of devices is known — the one question the devices genuinely cannot answer among themselves. A device that has never heard of a third sibling has no way to distinguish "there is no third device" from "the third device is asleep." Silence is not evidence.
So the roster is server-authoritative. The identity service holds the account's device rows, derives the account from the caller's session, and filters revoked devices out. The split is the interesting part: membership is server-authoritative while the content of the account is device-authoritative. The server decides who is in the set and can read nothing the set holds.
The two directions resolve in opposite ways. Absorbing a device inverts the usual flow — instead of presenting a password to a server, the new device proves control of the phone number with an ownership certificate, resolves its own account's discovery inbox while holding no session at all, and pushes a sealed sign-in request that existing devices approve. Then it takes a snapshot: a sibling exports its store, seals it, uploads it as a blob, and offers the key inside a sealed frame; the joiner fetches, verifies the SHA-256, decrypts, and imports wholesale — re-asking every five seconds up to seven times, because the relay has no mailbox and the sibling may be asleep. Why a snapshot and not a replay of history is Absorbing a New Device.
Expelling one is where the series states a debt. Removal marks the roster row revoked; the removed device discovers this on its next self-status poll, logs out, and re-onboards with a fresh key. That is cooperative by construction, and Revoking a Device was written before the fix ships, deliberately, to say what it leaves open. Joining and Leaving frames both directions.
8. Who is acting?
"All devices are equal" is the right rule for messages and the wrong rule for autonomous work.
Send a message from whichever device is in your hand; it fans out and everything converges. But run the nightly note-taking pass on your phone and your iPad and you get two sets of notes for one day. Join a conversation between agents from two of your devices and your agent shows up twice to a discussion among three people. Autonomous work must run exactly once, so the fleet has to elect — and there is no election service, since adding one would mean a server tracking your devices and their liveness.
The election is therefore built from what already exists. Rank is chronological by default: the earliest-registered device is primary, a total order needing no negotiation because the roster already carries it. A user-set device order overrides it, and that order itself syncs, so every sibling elects the same way. The seat claim rides the sealed heartbeat as two bytes — do I hold the seat, do I want you to answer now — so there is no presence service.
The failure modes it is designed against are named up front: orphaned control, where nobody drives because the taker crashed mid-handoff, and sustained overlap, where two devices drive and everything happens twice. The confirmed two-phase handoff avoids both by making them non-simultaneous: a holder that sees a higher-priority device come online quiesces — stops working, flushes context, but keeps asserting the token, a lame duck — and releases only once it has seen the new driver assert the seat. Both hold the token for a beat; only one is ever driving. Who Acts and The Driver's Seat.
9. Two paths that look like one
From a distance, sending to your iPad and sending to a friend look identical: same relay, same sealed envelope, same rotating subject. They are not the same path, and the difference is exactly two things — which key seals the frame, and which payload it carries. To a friend, a message under the channel key their handshake produced. To yourself, a sync_delta under the account-derived sibling key, self-gated on arrival.
That separation is load-bearing for a feature in the Agent series. Typing @pixie in a conversation summons your own agent into the thread, and the person you are talking to never learns it happened. The summon and the reply are stored as ordinary message rows flagged as agent-tag rows, never enqueued for delivery. They still appear on your iPad — because the messages namespace of the reconcile carries them, like any other row.
Derive why the friend cannot receive one. There are only two ways it could reach them. Either the peer-send path emits it — and that path has one chokepoint, ComposeService.assertPeerSendable, which throws on a summon-prefixed body before anything is sealed. Or the reconcile ships it — and a reconcile response is only ever produced in answer to a reconcile_req, which arrives only inside a sync_delta, which is only applied when the verified sender certificate carries our own account hash. A friend's device cannot produce that frame; it does not hold the key. The sync path structurally cannot address a non-sibling, and the peer path structurally refuses this content. Two independent mechanisms, neither of them a rule the code is trusted to remember.
10. What this does not give you
- Convergence is eventual, and eventual depends on devices coming online. A device that is off for a month is not wrong, only behind. But some operations need the whole fleet: a garbage-collection barrier involving it will not complete, so tombstones accumulate until it returns.
- The strongest deletion guarantee is contingent on iCloud. The cloud anchor that catches a restored-from-backup device exists only if that device has iCloud backup enabled. The Convergence Barrier and the Cloud Anchor says what remains true when it does not.
- Revocation is cooperative today. A removed device that never polls its own status is never told. Revoking a Device is the inventory of what closing that requires.
- The relay learns something, even though it can read nothing. Frames are unlinkable by subject and opaque by content, but a device subscribes to all of its pair subjects over one connection, which reveals that those subjects belong to one user — the code says so in its own comment, and an oblivious retrieval path exists as the alternative for offline catch-up. Separately, a snapshot request rides a distinct wake class, so a first-boot bootstrap is not shaped like routine traffic.
- None of this crosses accounts. Two people's devices never converge shared state through this machinery; group state is a different problem with different cryptography, and it lives in the Relay series.
11. The map
00 GroupThink (you are here)
│
├── 01 Staying in Sync
│ ├── 04 Mechanism: Reconciling by Digest
│ ├── 05 Mechanism: Clocks You Cannot Trust
│ ├── 06 Mechanism: Deleting Things That Stay Deleted
│ └── 07 Mechanism: The Convergence Barrier and the Cloud Anchor
│
├── 02 Joining and Leaving
│ ├── 08 Mechanism: Absorbing a New Device
│ └── 09 Mechanism: Revoking a Device
│
└── 03 Who Acts
└── 10 Mechanism: The Driver's Seat
Read top-down for the argument, bottom-up for the engineering. Everything below assumes Staying in Sync; if you read one mechanism, read The Convergence Barrier and the Cloud Anchor.
Three things this series does not cover, because a sibling series owns each. How an envelope is sealed, what the relay can observe, and how group cryptography works: The Relay. How two people find each other without the server learning who knows whom: Contact Discovery. What the agent riding on this coordination actually does: Agent.
A closing word on the name, because it labels a paradigm rather than a feature. Nothing in this series is something a device does by itself. A digest means nothing until another device compares it. A version vector is one device's account of what it has already heard from the others. A seat exists only because the rest of the fleet declines to take it. Even a deletion is not finished until every sibling has agreed to remember that it happened. GroupThink is that standing agreement between your devices — and its measure of success is that you never once notice it is being maintained.
References & further reading
- Leslie Lamport, Time, Clocks, and the Ordering of Events in a Distributed System (1978) — causal ordering without a shared clock, and the reason §5 has no timestamp in it.
- Shapiro, Preguiça, Baquero, Zawirski, A Comprehensive Study of Convergent and Commutative Replicated Data Types (INRIA, 2011) — the formal grounding for "commutative, idempotent merges converge without a coordinator."
- Version vector and Merkle tree — plain definitions of the two structures §4 and §5 are built from.
- Kleppmann et al., Local-first software (Ink & Switch, 2019) — the design tradition this series sits in, and what you lose along with the server.
- RFC 5869: HMAC-based Key Derivation Function (HKDF) — how one account secret becomes the sibling channel key and the rotating subjects of §2.
- The Relay and Contact Discovery — the transport and private matching this series assumes.
- The Homebase and The Agent in Your Day — the Agent claims §1 and §9 exist to support.
Next: Staying in Sync.