Files
amethyst/commons
Claude 5fc9aa9f58 fix(marmot): every member applies the commit that evicts a leaver, and it survives a restart
Two defects, both found by `leaver-removal-secrecy` and both isolated with a
failing test first. Between them a departing member stayed in the tree —
holding the group's keys and reading everything sent after they left — while
the group believed the departure had been processed.

A peer's proposal must be REFERENCED, not inlined
--------------------------------------------------
`commit()` inlined every staged proposal, including ones another member
authored. An inline proposal carries no sender, so a receiver attributes it to
the committer. For a `SelfRemove` that is not cosmetic: the proposal means
"remove my leaf", so inlining someone else's says "remove the COMMITTER's
leaf". Now only our own proposals go inline; a peer's goes in by
`ProposalOrRef.Reference`, which resolves against the receiver's own pool where
their copy of the same standalone proposal already sits with the original
proposer's leaf index. `PendingProposal.authenticatedContentBytes` documented
this contract all along; the code did not implement it.

A path-less commit must contribute a ZERO commit secret
--------------------------------------------------------
`commit()` derives path secrets unconditionally — it needs them to build the
UpdatePath when there is one — and then keyed the commit secret on whether
those secrets existed rather than on whether the path was actually SENT. A
SelfRemove-only commit omits the path (RFC 9420 §12.4.1), so every receiver
used the zero vector while the committer used a derived one: different epoch
secrets, and every witness rejected the commit with a confirmation-tag
mismatch and fell an epoch behind. The same branch also overwrote
`pathPrivateKeys` with keys that were never published, discarding the ones that
could still decrypt commits addressed to our ancestors.

The pool is an obligation, so it is durable
--------------------------------------------
`MlsGroupState` gains `pendingProposals` (STATE_VERSION 4; older blobs decode
with an empty pool), and staging a peer's standalone proposal now persists the
group the way an epoch change does — it only mutated memory before. Losing the
pool to a restart does not lose a message, it loses the obligation: nobody is
left holding the proposal that evicts the leaver.

That also makes `stageCommit`'s explicit hand-off of the pool redundant, so it
goes back to the shared `stage` helper and `adoptPendingProposals` is removed.

`leaver-removal-secrecy` now replays instead of asserting its own divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 13:22:23 +00:00
..