Jesper Nøhr / Tandem

Decisions

Date Decision
2026-11-11 Name: Tandem.
2026-11-11 One hub per client; collections don't span hubs.
2026-11-11 No OAuth-only providers: the IMAP binding uses passwords or app passwords, so Gmail and Outlook are out of scope.
2026-11-11 Data records keep version vectors, split into context and base so that a malformed context can't outlive its epoch (§9.2).
2026-11-11 Removals and demotions take effect at a key rotation. Inside an epoch nothing changes a data write's validity (§8.3). The cost: a demotion needs a rotation.
2026-11-11 Pairing always asks, showing the name the new device gives. In-person collection invites stay auto-approved. (The original decision had no code to compare; that was replaced on 2026-11-12, once pairing handed over the whole identity.)
2026-11-11 JMAP appends stay on Email/set, which batches; readers undo whatever transfer encoding the server chose (§10.4). The alternative, upload plus Email/import, would fix the stored bytes but cost a request per message.
2026-11-12 Tombstones are kept for good in v1 (§12.3).
2026-11-12 An old write enters a new epoch only through the owner's snapshot inventory, or by its author signing it again (§8.3, §11.6). The inventory says what may enter, not what must be present. A write the new state supersedes isn't signed again.
2026-11-12 An epoch is identified by the statement that starts it, and the channel tag derives from the key and that statement (§5.6, §7.1).
2026-11-12 Withdrawn designs. Over these two days the draft went through: per-device keys with a signed device chain, a recovery key and per-user key generations; ranked roles with admins; mergeable authorization logs; statement chains with a scored fork rule and "stubs"; a pinned controller device per collection; and an owner key with a designated device and take-over. Seven review rounds (Sol, Opus and self-reviews) kept finding defects, nearly all of them in rules for choosing between competing histories and for following a user's key changes. Only the first is in git (commit 98114c3); the others were never committed. model/tandem_model.py and model/rotation_model.py are models of two of them and are what remains.
2026-11-12 Cut to the requirements. A user is one secret, shared by their devices and printable as a backup; a user ID is the public signing key (§6). A collection has one owner for its whole life; members are writers or readers; only the owner changes sharing (§8). Writes stay signed, so authorship is provable and readers can't write. Two signed successors of one roster statement kill the collection, and nothing chooses between them (§9.4). A lost owner secret, a stolen device and a dead collection are all handled the same way: start again and copy the items (§6.4, §11.8). The owner's devices coordinate through a note in home, not through a designated device (§8.2, Q20). The collection's name is an owner-only record (§8.3). What this gives up: cutting off one stolen device (Q15), handing a collection to someone else, and recovering a collection in place.
2026-11-12 Review of the cut-down design (Sol): four defects in the details, none in who may do what. A new inventory carries forward entries the rotating device hasn't seen, so a write that was only missing from the hub can still be restored (§8.3). Old data is destroyed only once the new channel holds or has replaced it, and a device finishing another's change checks the snapshot against the inventory (§11.6, §11.7). A vector has at most 1,024 entries (§9.2). The note in home says what a pending change is, so it is never dropped without the user being told (§11.7).
2026-11-12 Final review pass. Pairing shows a six-digit code on both devices, bound to the new device's key and to a signed number from the showing device, so a photographed pairing QR can't be used to take the identity (§6.3). Invites moved from the roster to the owner's home, and the invite channel is keyed from the invite secret: members who don't hold an invite no longer see who asks to join through it, and creating an invite is no longer a roster change (§11.1). A join request signs the name; a removed member who asks for a key gets the signed statements that removed them; a member's uka, name and via can't change; a delete on a named record is invalid. A further review (Sol) of the vector limit: a field that needs more entries than a write may carry is not reset in place, because honest devices would sign their old writes again and undo it; the record is deleted and created again, which needs no new mechanism (§9.2). A write is not signed again if that would exceed the limit (§11.6). Only a valid write counts as having replaced another when old data is cleaned up (§11.6).
2026-11-12 Review (Sol), fourth pass: one removal race and three narrower points, none in the cryptography. An invite is closed for good once anyone it admitted has been removed, even if they later come back by another invite, and a former member is never admitted without the owner being asked; both are read from the signed roster, so a crash between removing someone and closing their invite in home no longer lets an old request back in (§11.3). Only the announcement says a key change was made: a missing note doesn't, because a note also goes when a change is discarded, and an unannounced statement is a draft, not a successor (§11.6, §11.7, §9.4). JSON that parsers read differently is invalid: repeated property names, numbers that aren't plain integers, bad UTF-8 (§10.5). The cost is that a client whose standard parser hides repeated names (Apple's Foundation does) needs its own check. Join requests can be read by everyone who holds the invite, not only by the owner; the claim was narrowed and requests are not sealed to the owner (§11.2).
2026-11-12 Review (Sol), fifth pass: no protocol finding, one model defect. The model let an invite reopen when a removed member was let back in through another invite; it now reads the roster's history, as §11.3 already said, and §11.1 and §11.3 say so in as many words. A last read of the whole draft added three clarifications and no rule: what a removed member can do about coming back (§3.3), that a discarded draft's channel is the one place a roster copy is destroyed (§10.7), and the prompt for re-adding someone who was removed (§13.2).
2026-11-12 Review (Sol), sixth pass: one gap, in how text is compared. Field names, property names, record names and channel tags are equal iff their UTF-8 bytes are; nothing is normalized or case-folded (§9.1, §10.5). Swift's string comparison treats equivalent Unicode spellings as equal and Python's doesn't, so two honest clients could keep different writes. For the same reason base64url has one spelling per value (§5.2). The cost: two field names that look alike can be different fields, which is the app's to avoid, and a Swift client compares bytes, not Strings.
2026-11-12 Findings from the testbed work, folded in before the draft was frozen. Clean-up: two messages that dominate each other need not hold the same writes, so the tie-break by envelope hash now covers every such pair, and byte-identical copies are removed only by the device that made them (§10.7). Bindings, from probing a real server (Stalwart 1.1.0): invalidArguments also means a full resync; the push timeout uses the interval the server reports; a JMAP reader checks isEncodingProblem; an IMAP connection cap can be per account; a device knows its own messages by envelope hash, because a push can beat the answer to its own append (§10.2–§10.4). A device picks a new device ID whenever the secret is installed on it, so a phone migration can't put one ID on two devices (§5.3, §6.3).