Session Lifecycle
Establishment
TwoMlsPqSession::initiate(client, their_kp, app_binding)— Alice builds her send group (Group_A): the ML-KEM-768 half first, the APQ-PSK exported from it, then the classical half bound by that PSK — carrying the optionalapp_binding(the app-state binding, welded into the GroupContext here and immutable for the session’s lifetime; see Group Rules rule 8). The app-layer welcome that identifies Alice is attached withset_initial_app_payload.pending_outbound()returns the first frame as one opaque §A.1 envelope —[app_payload ∥ APQWelcome_A]HPKE-sealed to Bob’s KP′ — so that welcome is hidden on the invitation channel (see Header Encryption).TwoMlsPqInvitation::open_initial(envelope) -> OpenedInitialthenreceive(welcome, their_classical_kp, bootstrap_kp_commitment, spawn_token, new_client_id, expected_remote, expected_app_binding)— Bob opens the envelope (the invitation holds the KP′ material), validates the app-layer welcome, and joins Group_A as his receive group (PQ half first, re-deriving the APQ-PSK, then the classical half), and builds his own send group (Group_B) classical half only, bound by a cross-party PSK exported from Group_A’s classical half — Group_B’s PQ half is deferred to the bootstrap (below), so Bob can send immediately.new_client_idselects an optional dedicated per-session principal at establishment: Group_B (and later its A.3 PQ half) is created under a freshly-minted principal with that id, so Alice sees the dedicated principal as the creator leaf of the very welcome she joins from — no first-frame rotation is needed. Alice adopts the id on the joining frame, surfacing it asremote_commit.new_sender; authenticity rides the cross-party PSK — only the invitation holder can create a Group_B that Alice’s join accepts.APQWelcome_B(with an empty PQ slot) rides as the staple on every frame Bob sends until his first commit (see Wire Format); a standalone copy is also inpending_outbound()for hosts that deliver it separately.- Alice joins Group_B as her receive group from whichever copy arrives first — the
staple on Bob’s first message frame, or a standalone
APQWelcome_Bviaprocess_incoming. Re-deliveries are idempotent no-ops (the session records the digest of the welcome it joined from; a different welcome on a live session isUnexpectedWelcome). Nowis_established()is true on both sides.
The PQ side-band
Three flows run beside the message path on their own tagged frames (see
Wire Format). This section is the caller’s view;
Protocol Flows §A.3–A.5 is the protocol they implement, and why the
epochs must line up as they do. A single turn alternates between the parties: the session
initiator owes the bootstrap; completing an operation passes the turn to the peer
(my_pq_turn()), and only one operation may be in flight at a time.
The host drives only the A.3 bootstrap and then ordinary sends — the SESSION self-drives A.4
and A.5. There is no begin(.ratchet/.rekey) for the host to call: on each encrypt, when it
is our turn and the side-band is idle (and never while it is wedged), the session opens the next
round automatically — an A.5 re-key when either leaf in the PQ half of our receive group (the
group our A.5 re-keys) lags, else an A.4 ratchet. A leaf lags when it presents a credential
id other than its owner’s current canonical (classically committed) id; a same-id key refresh
is not a lag. “A.4 begins immediately” is just the first send after the turn becomes ours; the
ratchet then ping-pongs, turn-gated so the two sides never both open at once. Staging is
best-effort (a transient KEM/proposal failure simply retries on the next send) and the staged
frame rides that send’s re-staple peek (pq_pending_outbound), so the host’s role is
.finishBootstrap plus sending messages. A deployed host deviates here; see
Shipped anomalies.
- Bootstrap (
0x13/0x15, then a stapled bind) — stands up Group_B’s deferred PQ half off the critical path: Alice sends her PQ key package (0x13) — the one PRE-COMMITTED atinitiate(bootstrap_kp_commitment()put its hash inside the signed establishment payload, and Bob’sreceivepinned it) — Bob verifies the hash (BootstrapKpMismatchotherwise), creates Group_B.pq around it and returns its Welcome (0x15); Alice joins and binds. Both send groups are then complete APQ groups (is_fully_established()). The round’s closing bind is not a side-band frame — it rides the next message frame’s staple. The bind is structurally the PQ ratchet’s (below), differing only in where its injected secret comes from — an exporter off the newly joined group rather than a KEM exchange — and it doubles as the round’s receipt: that secret is derivable only from inside Group_B.pq, so a bind that applies at all proves Alice joined. - PQ ratchet (
0x17/0x19, then a stapled bind) — injects fresh ML-KEM entropy into a send group’s PQ half via a pathless PSK commit and re-binds the exported APQ-PSK into the classical half in the same round. Opened automatically by the turn-holder’s next send (no host call): it auto-stages the initiator’s ML-KEM encapsulation key (0x17); the responder answers with its ciphertext plus the AEAD-sealed injected secret (0x19), and the closing bind rides the next message frame’s staple. Both legs are MLS application messages in their own sender’s classical send group — the entropy they carry is PQ, the transport that authenticates them is not (see Wire Format). - PQ re-key (
0x1B/0x1D, then a stapled bind) — updatePath commits run on the two send groups’ PQ halves alone, so the classical ratchet is never blocked behind a large ML-KEM updatePath. It is not a host call either: the session opens it in place of an A.4 when either leaf in the PQ half of our receive group lags its owner’s canonical principal. When that leaf is ours (a Phase 8 classical rotation moved the session client), ourUpd'’s new leaf carries our principal as the handoff. When it is the peer’s, the peer’s responderCommit'carries the peer’s principal onto its own send-PQ leaf: the reciprocal catch-up. The initiator’s send auto-stagesUpd'(self)into the PQ half of the peer’s send group (0x1B); the responder commits it with its ownCommit'(pq_rekey_respond,0x1D) — whose updatePath rotates the committer’s leaf and cross-injects a PSK exported from the PQ half of the opposite send group. The round’s third leg is not a side-band frame: the initiator acks with a pathless partial commit stapled onto its next classical commit (pq_rekey_apply), a FULL commit whoseAppDataUpdatereconciles the bumpedpq_epochin-round. (A rotation that lands while an A.4 is staged, or while an A.5Upd'is in flight, does not re-mint that round. Likewise, a responder whose own rotation staple has not yet applied answers with aCommit'that moves nothing. Either way the leaf still lags after the round, and the next turn’s trigger opens the catch-up: a race costs one extra round, never a stall.)
Routing
The session tells the transport where to listen and post; both derive from
exportSecret(label="rendezvous", context="TwoMLS", len=32) on a group’s classical
half, so the two ends compute identical addresses:
should_listen_on()— the send group’s ids plus one rendezvous address per retained classical epoch. Listening works from birth; exporters are only derivable at their epoch, so each address is captured live, and the window follows mls-rs’s own epoch retention (traffic posted at a recently prior epoch’s address still lands).send_rendezvous()— where to post: the receive group’s exporter at its current epoch. The receive group is the peer’s send group, so this value appears verbatim in the peer’s listen set.Noneuntil the receive group exists (the initiator’s first frame travels via the invitation channel instead).
Sending
Sending is two-phase so CommProtocol can bind a per-round proposal hash:
prepare_to_encrypt(proposing)— stages key material and returns aPrepareEncryptResult { proposal_message, proposal_hash, committed_remote_client_id, did_commit }.proposal_messageis the stagedUpd(self)proposal, raw — every round stages one, rotation rounds included — andproposal_hashits 32-byte SHA-256; the receiver independently derives the same value asQueuedRemoteProposal.digest. A host that must sign over the proposal (the anchor agent handoff binds the rotation Upd’s digest) digestsproposal_messageitself: bytes and hash come from the same critical section, so no later prepare can interpose a different Upd. Through the Swift package, derive that digest withPQDigest.over(_:)— not a hand-rolled hash — so it stays equal toproposalHashwhen the suite’s digest changes.proposing: None→ routine round. Our own send group commits in two cases, both gated on the peer having applied our previous commit: when a queued, app-approved remote proposal is pending (it folds the proposal —did_commit: true, and the cross-party PSK refreshes if the peer’s send group has advanced since the last binding), or when an owed PQ bind must be discharged (a proposal-less, updatePath-only commit —did_commit: truewith nothing folded, so PQ liveness never waits on app approval policy). A round with neither pending commits nothing.proposing: Some(new_client_id)→ this round’s Upd proposes a rotation to that ClientId, admitting the candidate on the fly (see Principal key rotation below).
encrypt(app_message)— binds the pendingproposal_hashinto the message’s authenticated data and returnsEncryptResult { cipher_text, sender, recipient, epochs }, whereepochsis the send group’s epoch pair —pq_epoch(0 while that half is deferred) andclassical_epoch(the message epoch). The frame is always the[staple][proposal][app]triple: the staple (our latest send-group commit, or our APQWelcome until the first commit) rides every frame, so a peer that missed a frame is healed by the next one.
Receiving
process_incoming(ciphertext) returns Option<DecryptResult>:
application_message— a decrypted app message.proposal— the peer’s stapledUpd(sender)proposal, offered for app approval (thenqueue_proposal(digest)).remote_commit— aCommitResult, surfaced on the delivery that applied the staple or performed the welcome join (peer rotated, or established under a dedicated principal →new_sender); repeats of an already-applied staple are silent skips. A standalone welcome that adopts a dedicated peer principal returns aDecryptResultwith onlyremote_commitset — the handoff is observable whichever copy of the welcome arrives first.new_senderis an event hint;their_principal_state()is the truth (the signal is lost if the same frame’s app message fails).None— a welcome that changed nothing to announce (a re-delivery already joined from, or a first join under the peer’s expected identity), or a message for an unknown epoch (re-establish the session — not recovered in-library).
A stapled commit ahead of the receive group’s next epoch fails with EpochDesync
before the app ciphertext is touched: the peer advanced more than one commit past us
and the bridging commit no longer rides any frame — re-establish territory,
distinguishable from a transient DecryptionFailed (e.g. a message frame that
overtook its A.4 BIND, which succeeds on retry once the BIND lands).
Remote proposals & the folding commit
When process_incoming surfaces a proposal, CommProtocol orders it against its own
sequence number and, if accepted, calls queue_proposal(digest). The next
prepare_to_encrypt(None) then commits it (did_commit: true), advancing the send
epoch and refreshing the PSK binding.
Principal key rotation
Rotation is proposal-driven (see Group Rules — the
Authentication Service) and lazy — there is no separate stage call:
prepare_to_encrypt(Some(new_id)) makes this round’s stapled Upd(self) carry the
successor’s credential, minting the successor (the app supplies only the opaque
ClientId; signing keys are session-owned) and authorizing it on the fly if new_id is
not already a candidate. Admitting a candidate marks the session Pending. Different
rounds may propose different candidates — the app orders them. The peer surfaces each candidate as
QueuedRemoteProposal.proposing, approves one with queue_proposal (the running
tally), and its next commit canonicalizes it: committed_remote_client_id and
their_principal_state report the new identity on the committing side, and the
commit staple’s return canonicalizes the proposer (remote_commit.new_recipient,
my_principal_state → Sync { new }, the session swaps to the winning principal).
Because rotation rides the proposal slot, it can be offered on the very first frame —
it can never displace the welcome staple. A candidate proposed on the wire is never
dropped (the peer may commit any of them); staging beyond the in-flight window parks
the request in a single deferred slot and proposes it on the next routine round once a
slot frees. On the receiver, queue_proposal is a single-occupancy latest-wins tally
(queued_remote_successor() reveals it), epoch-locked so it is dropped when the send
epoch advances by an A.4 bind.
The winner’s other leaves lag and catch up: the proposer’s own send-group leaf
moves at its next approved commit (the peer observes new_sender on that staple, and
message attribution follows). A PQ leaf minted at A.3 is born under its owner’s
then-canonical id; every other PQ leaf catches up over A.5 rounds, one per PQ group, which
the session self-drives. The rotated party’s own
A.5 carries its current, already-canonical principal onto its leaf in the PQ half of
the peer’s send group. The peer’s next turn then
opens the reciprocal A.5, because the rotated party’s leaf in the peer’s receive group
still lags. The rotated party answers as responder, and its Commit' carries the
credential onto its own send-PQ leaf. The acceptor’s recv-group leaf
converges from the invitation identity to the dedicated establishment principal via its
first committed Upd. Every catch-up is validated
against the AS history window, and a credential that a live PQ leaf still presents stays
admissible past window eviction until that leaf catches up (see
Group Rules, rule 4).
The deployed engine deviates from this catch-up; see Shipped anomalies.
For the common “dedicated agent per session” pattern, don’t rotate at establishment
at all: pass the agent’s id to receive(…, new_client_id:) and the session is born
under it (Establishment, above).
Invitations & replayed initial frames
A published key package is backed by a self-contained TwoMlsPqInvitation (the
credential plus the key package’s private material, including its signing keys) rather
than a live client;
one invitation services many welcomes, deduplicating repeats per remote
(DuplicateWelcome). receive(welcome, their_classical_kp, bootstrap_kp_commitment, spawn_token) takes an opaque, caller-chosen, replay-stable token for the initial frame
and records
token → the spawned session's receive group in a forward table:
-
forward_group_id(spawn_token)—Somemeans this exact initial frame was already accepted; route the payload to the owning session instead of surfacing a fresh welcome. -
TwoMlsPqSession::forwarded(spawn_token)— the session acknowledges the re-delivery (Ok(None)always: the call only validates the routing — a pre-establishment frame staples the sender’s current app message §A.1-style, and the host delivers that staple by parsing the envelope (decode_initial_plaintext) and feeding it toprocess_incoming); a mismatched token is a mis-route. -
processed_welcome_group_id(welcome)— the content-keyed counterpart of the forward table: resolves a re-delivered welcome (by the digest of its exact bytes) to the spawned session’s receive group, with no host token convention needed.receiveconsults the same ledger up front and rejects a re-delivery asDuplicateWelcomebefore claiming or reserving anything.
The invitation pushes these to its ArchiveSink after every receive — the consumed
set, the forward table, the processed-welcome ledger, and the bootstrap-commitment routing
table (contract 23) — so all four survive a restore. The token is opaque
to this crate — the caller picks the convention (Germ’s adapter digests the envelope’s
STABLE PREFIX — the app payload, else the bare welcome — so every pre-establishment
re-staple from the same initiator resolves to the same token).
Session profiles
The behavior this book specifies is the correct profile. A second profile, deployed-compatible, adds two behaviors that keep a session with the deployed engine healthy:
- Announced id. An A.5
Upd'that changes its leaf’s credential id also carries the new id in its authenticated data. A key-onlyUpd'carries none. Deployed hosts read the announced id to trigger reconciliation. - Deferred reciprocal. The reciprocal A.5 waits until the peer’s own A.5 has landed, meaning the peer’s leaf in our send-PQ group presents its current canonical id. Until then our turn opens an A.4, so the peer can still run its own round. This is the accommodation for anomaly 5 below. The correct profile opens the reciprocal as soon as the peer’s leaf lags.
In both profiles a receiver accepts an Upd' whose authenticated data is empty or equals
the new leaf’s credential id, and rejects any other value.
Deployed-compatible is the default and is frozen: it changes only to fix a defect or to follow the deployed engine. The profile is chosen once per session, from the two key packages (Group Rules, rule 9), and never changes, not even when a peer upgrades. A client advertises a profile only once it implements that profile completely. The deployed engine advertises none, so every session with it runs deployed-compatible, and so does every session created before profiles existed. A session migrated from the deployed engine is deployed-compatible too; its groups carry no record. Two clients that both advertise the correct profile start in it, with no flag day. A later wire-visible change to the correct behavior ships as a new profile with its own extension type, never as a change to a shipped one.
Shipped anomalies
This book specifies intended behavior. The deployed Rust engine, and the card host it shipped in, deviate from it in six ways. Each item notes how it resolves: healed by a conforming peer following the spec, healed only once the deployed party runs a conforming engine, or needing an accommodation beyond the spec.
- Wrong trigger. The deployed engine opens an A.5 when its own send-PQ leaf lags, not when a leaf
in its receive group lags, and it never opens the reciprocal A.5. Its own round can
only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided
rotation, the rotated party therefore opens another A.5 on every PQ turn it holds
(after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its
pre-rotation credential.
- Resolution: healed by spec behavior. The deployed responder
Commit'does carry its current credential, so a conforming peer’s reciprocal A.5 completes the catch-up and the loop stops. In the other direction, a conforming rotated party’s own send-PQ leaf stays behind against a deployed peer. That needs no accommodation: rule 4 already has it keep signing that group with the key its leaf presents.
- Resolution: healed by spec behavior. The deployed responder
- Born-dedicated acceptor’s PQ leaf. A deployed born-dedicated acceptor never
catches up its leaf in the initiator’s send-PQ group: its own send-PQ leaf was minted
at A.3 under the dedicated id, so its trigger never fires.
- Resolution: healed once the acceptor runs a conforming engine, whose own A.5
fires. The peer cannot heal it, since only the acceptor’s own
Upd'moves that leaf.
- Resolution: healed once the acceptor runs a conforming engine, whose own A.5
fires. The peer cannot heal it, since only the acceptor’s own
- Born-dedicated acceptor’s catch-up Upds. They propose the identity the peer
already treats as canonical (
proposing == sender). A host that folds only offers whereproposingdiffers fromsendernever commits one, so the acceptor’s recv-group leaf keeps presenting the invitation identity.- Resolution: healed by the peer’s host. A conforming engine marks such an offer as a catch-up, which the host can approve without authorizing a new credential.
- History window. The deployed AS pins only the A.3 founding ids.
- Resolution: healed by spec behavior wherever a conforming engine validates the move, because it keeps any id a live PQ leaf presents admissible (rule 4). A deployed validator still refuses a leaf left behind for longer than the history window, and nothing heals that.
- Unchecked join. The deployed engine’s A.3 bind joins the peer’s send-PQ group signing with its
current PQ key, not the KP′ key its leaf there presents (contrary to rule 4). After
a rotation before the bind, its A.5
Upd'in that group is mis-signed and always rejected, and the presented key survives only as its own send-PQ group’s signer. When it later answers a peer’s A.5, its responderCommit'replaces that signer, and the leaf is orphaned for good: no copy of its key remains.- Resolution: needs an accommodation: the deployed-compatible profile’s deferred
reciprocal (Session profiles), which every session with the
deployed engine runs. Against a conforming peer it costs at most one extra round.
A conforming engine that takes the party over drops its mis-signed parked
Upd'and re-proposes under the carried key, which heals it. Once the leaf is orphaned, nothing heals it.
- Resolution: needs an accommodation: the deployed-compatible profile’s deferred
reciprocal (Session profiles), which every session with the
deployed engine runs. Against a conforming peer it costs at most one extra round.
A conforming engine that takes the party over drops its mis-signed parked
- The deployed card host never sends side-band frames. It ships the initiator’s KP′
inside an ordinary message, which the acceptor answers, standing up its send-PQ half
and parking
Welcome'. But the host never sends that frame, so the initiator waits for it indefinitely, and neither side becomes fully established. No A.4 or A.5 ever runs, so the session keeps only the PQ protection its establishment seeded. The same host does receive side-band frames and hands them to the session, and the deployed anchor host co-sends each parked frame with the next message, so anchor sessions complete A.3.- Resolution: healed once the acceptor’s host carries side-band frames, as
The PQ side-band specifies and the anchor host already does.
No protocol change is needed. An upgraded acceptor sends its parked
Welcome'. The deployed initiator binds, and its bind rides an ordinary frame, so A.3 completes without the initiator upgrading. Every later A.4 or A.5 needs the deployed party to send a side-band leg, so those rounds stay open, without error, until it upgrades. How often a host re-sends its parked leg is host policy, but a host must never drop it. A deployed acceptor cannot be healed from the initiator’s side, because only it holds theWelcome'.
- Resolution: healed once the acceptor’s host carries side-band frames, as
The PQ side-band specifies and the anchor host already does.
No protocol change is needed. An upgraded acceptor sends its parked