Problem
DirectMessagesSummary.message_signatures (common/src/room_state/direct_messages.rs:1391) carries raw 64-byte Ed25519 signatures, one per DM held, capped at DEFAULT_MAX_DIRECT_MESSAGES = 300.
Measured (real ComposableState::summarize + ciborium::into_writer, the exact pair summarize_state uses): SignatureBytes encodes to 66 CBOR bytes, so this field is 19,803 bytes at the 300-DM cap — larger than the entire rest of the summary combined.
delta() uses it for a bare contains() and nothing else (direct_messages.rs:987-994). It is a value that is only ever compared for equality and never recovered — the textbook case for a digest.
.claude/rules/contract-summary-determinism.md already names this as the deferred half of #571. PR #571 did the same fix for member_info (value (u32, Signature) -> (u32, SigDigest)) and measured 18,803 -> 3,894 bytes at 139 members, i.e. 135.27 -> 28.01 bytes/entry. This is the remaining half.
Why it matters beyond River
Contract summaries are ~23.7% of all Freenet outbound bytes, and a fleet-wide measurement puts the mean summary at 16,675 bytes against a protocol digest-entry size of 21 bytes (freenet-core#5153). Summary size also sets the floor for a queued freenet-core fix that replaces a blind 111 KB full-state send with a summary exchange — so every byte cut here compounds.
Fix
Replace SignatureBytes with a 16-byte BLAKE3 digest in three places: message_signatures, and the signature inside DmOrderKey (which reaches pair_horizons and global_horizon).
Measured: 19,803 -> 5,103 bytes at the 300-DM cap (3.88x).
Use 16 bytes, not 8. An 8-byte digest gives 2,703 bytes (7.3x) but is grindable, and a DM-signature collision means a DM silently never propagates to a peer. River already has the right primitive in state: PurgeToken is a 16-byte BLAKE3 of the DM signature, so reusing it costs no new derivation.
Correctness coupling — flag this in review
DmOrderKey is also the in-state retention ordering key. Ordering by digest is a different total order than ordering by raw signature. sort_state and the horizon comparison m.order_key() > **oldest must switch together, or the horizon silently changes meaning and re-opens the resend loop that #485 closed.
Related, not in scope here
- Scope. Every peer's summary advertises every DM in the room to every member, participant or not — a per-broadcast cost and a privacy leak of exact DM volume. The module docs (
direct_messages.rs:99) already name per-member DM contracts as the intended fix.
SecretsSummary.member_secrets is a latent bomb for private rooms. It is BTreeSet<(SecretVersion, MemberId)> — one entry per (member x secret version) — measured at 27,649 bytes for 200 members x 20 versions. No live private room was available to measure. Worth its own issue before a private room hits that shape.
Cost to weigh
This re-keys the room contract and strands a 32nd legacy generation. Every stranded generation keeps failing anti-entropy forever (freenet-core#5153 and its stranded-generation sub-issue), so there is a real argument for landing the freenet-core retention fix first and batching River summary changes rather than shipping them one at a time.
Testing
Golden-vector test pinning the digest and its CBOR encoding (one fixed input, one fixed expected digest, one fixed expected byte length) — a randomised oracle misses byte-order bugs intermittently. Plus a test asserting sort_state and the horizon comparison agree under the new ordering.
[AI-assisted - Claude]
Problem
DirectMessagesSummary.message_signatures(common/src/room_state/direct_messages.rs:1391) carries raw 64-byte Ed25519 signatures, one per DM held, capped atDEFAULT_MAX_DIRECT_MESSAGES = 300.Measured (real
ComposableState::summarize+ciborium::into_writer, the exact pairsummarize_stateuses):SignatureBytesencodes to 66 CBOR bytes, so this field is 19,803 bytes at the 300-DM cap — larger than the entire rest of the summary combined.delta()uses it for a barecontains()and nothing else (direct_messages.rs:987-994). It is a value that is only ever compared for equality and never recovered — the textbook case for a digest..claude/rules/contract-summary-determinism.mdalready names this as the deferred half of #571. PR #571 did the same fix formember_info(value(u32, Signature)->(u32, SigDigest)) and measured 18,803 -> 3,894 bytes at 139 members, i.e. 135.27 -> 28.01 bytes/entry. This is the remaining half.Why it matters beyond River
Contract summaries are ~23.7% of all Freenet outbound bytes, and a fleet-wide measurement puts the mean summary at 16,675 bytes against a protocol digest-entry size of 21 bytes (freenet-core#5153). Summary size also sets the floor for a queued freenet-core fix that replaces a blind 111 KB full-state send with a summary exchange — so every byte cut here compounds.
Fix
Replace
SignatureByteswith a 16-byte BLAKE3 digest in three places:message_signatures, and thesignatureinsideDmOrderKey(which reachespair_horizonsandglobal_horizon).Measured: 19,803 -> 5,103 bytes at the 300-DM cap (3.88x).
Use 16 bytes, not 8. An 8-byte digest gives 2,703 bytes (7.3x) but is grindable, and a DM-signature collision means a DM silently never propagates to a peer. River already has the right primitive in state:
PurgeTokenis a 16-byte BLAKE3 of the DM signature, so reusing it costs no new derivation.Correctness coupling — flag this in review
DmOrderKeyis also the in-state retention ordering key. Ordering by digest is a different total order than ordering by raw signature.sort_stateand the horizon comparisonm.order_key() > **oldestmust switch together, or the horizon silently changes meaning and re-opens the resend loop that #485 closed.Related, not in scope here
direct_messages.rs:99) already name per-member DM contracts as the intended fix.SecretsSummary.member_secretsis a latent bomb for private rooms. It isBTreeSet<(SecretVersion, MemberId)>— one entry per (member x secret version) — measured at 27,649 bytes for 200 members x 20 versions. No live private room was available to measure. Worth its own issue before a private room hits that shape.Cost to weigh
This re-keys the room contract and strands a 32nd legacy generation. Every stranded generation keeps failing anti-entropy forever (freenet-core#5153 and its stranded-generation sub-issue), so there is a real argument for landing the freenet-core retention fix first and batching River summary changes rather than shipping them one at a time.
Testing
Golden-vector test pinning the digest and its CBOR encoding (one fixed input, one fixed expected digest, one fixed expected byte length) — a randomised oracle misses byte-order bugs intermittently. Plus a test asserting
sort_stateand the horizon comparison agree under the new ordering.[AI-assisted - Claude]