Files
amethyst/commons
Claude 9d912ad82a fix(marmot): receive standalone SelfRemove proposals (test 15)
Two bugs that conspired to break interop test 15 once the test 14 OOM
was fixed:

1. **No receive path for standalone PublicMessage proposals.**
   wn/openmls publishes a non-admin's `SelfRemove` as a kind:445 carrying
   a `PublicMessage(content_type=PROPOSAL)` envelope and waits for an
   admin to fold it into the next commit. Quartz's `MarmotInboundProcessor`
   answered every such event with `Error("Standalone proposals not yet
   supported")` and dropped it. The admin's subsequent commit then
   failed with `Commit references unknown proposal (ref not found in
   pending proposals)` because nobody had staged the SelfRemove.

   Add `MlsGroup.receivePublicMessageProposal(pubMsg)` that:
   - rejects mismatched epoch / group_id / sender,
   - reconstructs the FramedContentTBS exactly as the proposer did and
     verifies the leaf signature,
   - verifies the membership_tag against the current epoch's
     membership_key (same threat model as inbound PublicMessage commits),
   - decodes the inner Proposal (only `SelfRemove` is accepted today —
     other types come bundled in commits' `proposals` lists),
   - stages the proposal in `pendingProposals` so a later commit can
     resolve its `ProposalRef`.

   `processPublicMessage` now routes `ContentType.PROPOSAL` through
   that helper and returns a new `GroupEventResult.ProposalStaged`
   variant (also surfaced as `MarmotIngestResult.ProposalStaged`),
   replacing the old hard-error path.

2. **Wrong `ProposalRef` hash input.** RFC 9420 §5.2 specifies that a
   `ProposalRef` hashes the **encoded `AuthenticatedContent`** that
   delivered the proposal, not the bare `Proposal` struct. Quartz was
   hashing `proposal.toTlsBytes()` only — fine for our local-only
   flows where commits inline rather than reference our own pending
   proposals, but fatal once we needed to match wn's reference to an
   inbound proposal.

   Extend `PendingProposal` with an optional `authenticatedContentBytes`
   field. The standalone-proposal receive path captures the full
   `wire_format || FramedContent || FramedContentAuthData` envelope at
   stage time. The reference-resolution code in `processCommitInner`
   prefers those bytes when present and falls back to the bare-proposal
   hash for locally-proposed entries (which never get referenced
   today).

Marmot interop score: 14/16 → 15/16 (test 9 — amy's kind:7 reaction
triggers a `SecretReuseError` on B's wn — is unrelated to this path
and remains for follow-up).

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 02:22:04 +00:00
..