Skip to content

fix: deterministic contract summaries (BTreeMap/BTreeSet) to stop spurious anti-entropy heals - #416

Merged
sanity merged 1 commit into
mainfrom
fix/deterministic-summaries
Jul 19, 2026
Merged

fix: deterministic contract summaries (BTreeMap/BTreeSet) to stop spurious anti-entropy heals#416
sanity merged 1 commit into
mainfrom
fix/deterministic-summaries

Conversation

@sanity

@sanity sanity commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Problem

freenet-core byte-compares the room contract's summarize_state output to decide peer staleness (is_stale). Several ComposableState::Summary types used HashMap/HashSet, which serialize (via ciborium) in a per-process-random order. Two peers holding the identical room state therefore produced different summary bytes → the equal-summary skip never fires → the anti-entropy heartbeat fires spurious full-state heals for every room (~20M summarize_contract_state calls seen in production). This also feeds the update-drop divergence in freenet/freenet-core#4857.

Audit — every ComposableState::Summary type

Field type Summary Verdict
configuration (AuthorizedConfigurationV1) u32 OK — primitive
bans (BansV1) HashSet<BanId> FIX → BTreeSet<BanId>
members (MembersV1) HashSet<MemberId> FIX → BTreeSet<MemberId>
member_info (MemberInfoV1) HashMap<MemberId,(u32,Signature)> FIX → BTreeMap<…>
secrets (RoomSecretsV1) SecretsSummary { version_ids: HashSet<SecretVersion>, member_secrets: HashSet<(SecretVersion,MemberId)> } FIX → BTreeSet × 2
recent_messages (MessagesV1) Vec<MessageId> OK — messages kept sorted by (time,id) in apply_delta
direct_messages (DirectMessagesV1) DirectMessagesSummary { message_signatures: HashSet<SignatureBytes>, purge_versions: Vec<(MemberId,u64)> } FIX message_signaturesBTreeSet (purge_versions already sorted)
upgrade (OptionalUpgradeV1) Option<u8> OK
version (StateVersion) u32 OK
top-level ChatRoomStateV1Summary macro-generated struct, fields in declaration order OK once leaves are fixed

SignatureBytes gains Ord/PartialOrd (over its raw 64 bytes) so it can live in a BTreeSet. All other keys were already Ord.

Deltas: none contain a HashMap/HashSet; each is a Vec/struct built from canonically-ordered state Vecs, and freenet-core does not byte-compare deltas — so no Delta change was needed.

Approach

Convert only the nondeterministic summary collections; no STATE type changes. validate_state still accepts existing stored state byte-for-byte — only summarize_state output ordering changes. Convergence semantics (member_info_rank selection, equal-version tiebreak, etc.) are preserved.

Testing

New common/tests/summary_determinism_test.rs: for each fixed summary type and the top-level ChatRoomStateV1Summary, build the same logical summary with elements inserted in two different orders, serialize with ciborium::ser::into_writer (exactly what summarize_state uses), and assert byte-identity. The tests reference the actual <T as ComposableState>::Summary associated type, so they fail on a HashSet/HashMap regression — verified by temporarily reverting BansV1::Summary to HashSet (the bans and top_level tests then fail). Full river-core (220+), riverctl (170+), and river-ui --bins (447) suites stay green; cargo fmt clean.

Migration (WASM change → new contract + delegate keys)

The change alters both the room-contract and chat-delegate WASM, so both keys change. Registered the current (deputy-generation, live-on-network) hashes as legacy V27 in legacy_delegates.toml and common/legacy_room_contracts.toml, resynced WASMs, and bumped river-core 0.1.14→0.1.15 + riverctl 0.1.76→0.1.77 (the room-contract WASM embeds the crate SVH, so the version bump itself changes the bytes — WASMs were resynced after the bump). cargo make check-migration, check-room-contract-migration, the ui/cli WASM sync check, and the no-wasm-bindgen-imports check all pass.

WASM old (V27 legacy, deputy gen) new (this PR)
room_contract 2e9a1eda… c53ded28…
chat_delegate ee08e689… 82da3a0e…

Recurrence guard

Adds .claude/rules/contract-summary-determinism.md (the rule: contract Summary/Delta types must use BTreeMap/BTreeSet/sorted-Vec, plus the required determinism test and the WASM-migration coupling) and references it from AGENTS.md.

Refs freenet/freenet-core#4857

[AI-assisted - Claude]

…rious anti-entropy heals

## Problem
freenet-core byte-compares the room contract's `summarize_state` output to
decide peer staleness (`is_stale`). Several `ComposableState::Summary` types used
`HashMap`/`HashSet`, which serialize (via ciborium) in a per-process-random
order. Two peers holding the IDENTICAL room state therefore produced DIFFERENT
summary bytes, so the equal-summary skip never fired and the anti-entropy
heartbeat fired spurious full-state heals for every room (~20M
`summarize_contract_state` calls seen in production). This also feeds the
update-drop divergence in freenet/freenet-core#4857.

## Approach
Convert every nondeterministically-serialized Summary collection to a
deterministic one (same logical contents, only ordering/serialization changes):
- BansV1::Summary        HashSet<BanId>                          -> BTreeSet
- MembersV1::Summary     HashSet<MemberId>                       -> BTreeSet
- MemberInfoV1::Summary  HashMap<MemberId,(u32,Signature)>       -> BTreeMap
- SecretsSummary         version_ids/member_secrets HashSet      -> BTreeSet
- DirectMessagesSummary  message_signatures HashSet<SignatureBytes> -> BTreeSet
  (SignatureBytes gains Ord/PartialOrd over its raw 64 bytes)

No STATE type changes: validate_state still accepts existing stored state
byte-for-byte; only summarize_state output ordering changes. All keys were
already Ord. Deltas are unchanged (built from canonically-ordered state Vecs;
not byte-compared by freenet-core). The macro-generated top-level
ChatRoomStateV1Summary is an in-order struct, so fixing the leaves fixes it.

## Testing
New common/tests/summary_determinism_test.rs: for each fixed summary type
(and the top-level summary), build the same logical summary with elements
inserted in two different orders, serialize with ciborium (exactly what
summarize_state uses), and assert byte-identity. Tests reference the actual
<T as ComposableState>::Summary associated type so they FAIL on a HashSet/
HashMap regression (verified: reverting bans to HashSet fails both the bans and
top-level tests). Full river-core / riverctl / river-ui suites stay green.

## Migration (WASM change -> new keys)
The change alters both the room-contract and chat-delegate WASM, so both keys
change. Registered the current (deputy-generation, live-on-network) hashes as
legacy V27 in legacy_delegates.toml and common/legacy_room_contracts.toml,
resynced WASMs, and bumped river-core 0.1.14->0.1.15 + riverctl
0.1.76->0.1.77 (the room-contract WASM embeds the crate SVH). check-migration,
check-room-contract-migration, and the ui/cli WASM sync check all pass.

Adds .claude/rules/contract-summary-determinism.md and references it from
AGENTS.md.

Refs freenet/freenet-core#4857

[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B49wBfvR8EjpfYTw5muNV9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant