Repository navigation
Description
Summary
pkgs/id chat presence caps a heartbeat at MAX_HEARTBEAT_CHANNELS (64) channels, but the receive path applies that cap to the raw channel list without deduplicating first — while the constructor deduplicates before capping. A wire heartbeat whose channel list repeats one channel 64 times and then names a second, genuinely distinct channel is stored with only the repeated channel: the distinct channel is silently dropped, and the node reads as not present in it, even though the heartbeat names just 2 distinct channels, far under the cap.
Evidence (verified this run, main d1de7ee; chat_presence.rs md5 68590664a175bd4a17937c28a2f0fb6b, unchanged at this HEAD)
PresenceHeartbeat::new(pkgs/id/src/chat_presence.rs:110-118) deduplicateschannelsintouniquefirst, and only then truncates toMAX_HEARTBEAT_CHANNELS— so a constructed heartbeat keeps up to 64 distinct channels.PresenceTable::update_at(chat_presence.rs:261, channels filter at :273-279), the receive path for heartbeats decoded from the wire (which never pass throughnew), does.filter(is_member).take(MAX_HEARTBEAT_CHANNELS)on the raw list — no dedup step — so duplicate entries consume cap slots.MAX_HEARTBEAT_CHANNELS(chat_presence.rs:74-77) is documented as "The most channels one heartbeat may name" — a bound on distinct channels, which the receive path does not deliver.- Laptop probe
~/git/fleet/probe-2026-11-02-laneA-presencechannels/(verbatimchat_presence.rs, md5 68590664a175bd4a17937c28a2f0fb6b, matching current main; same stub harness as the id(chat-presence): heartbeat typing list is never deduplicated or bounded — one channel yields a 3.3 MB signed frame, defeating MAX_HEARTBEAT_CHANNELS #3991 probe):- Control:
PresenceHeartbeat::new(node, [A×64, B], …)→channels.len() = 2, B kept. - Wire shape: the same list round-tripped through
encode_frame/decode_frame(65 raw entries) and accepted byupdate_at→liveness_at(node, A) = Live,liveness_at(node, B) = Offline. B was crowded out by the duplicates. - Second shape:
[A×63, B, C](3 distinct) via the wire path → BLive, COffline; the constructor with the same list keeps all 3.
- Control:
Why it matters
Presence correctness depends on the stored channel set being the set the heartbeat actually claims. On the receive path — the only path untrusted wire data takes — that set is currently "the first 64 raw entries", so a buggy or crafted (but validly self-signed; the sender signs its own heartbeat, so verify() passes) heartbeat loses presence in channels it genuinely belongs to, with no error anywhere: the heartbeat is accepted, the node simply never shows as live in the displaced channels. It also makes the stored channels list a multiset rather than the set the type documents, so any future consumer that iterates or counts it inherits the duplicates. The constructor already implements the intended semantics; the receive path diverges from it.
Acceptance criteria
PresenceTable::update_atdeduplicates a decoded heartbeat'schannels(preserving first-seen order, asnewdoes) before theMAX_HEARTBEAT_CHANNELScap is applied, so the cap bounds distinct channels on the receive path just as in the constructor.- Stored heartbeats never contain duplicate channel entries, regardless of the path they arrived by.
- Regression tests: a wire-shaped heartbeat with
[A×64, B]is stored present in both A and B; a heartbeat with[A×63, B, C]is stored present in all three; existing chat_presence tests keep passing.
Distinct from: #3991 (the typing list is never deduplicated or bounded — a frame-size/DoS gap in the sibling field; this issue is the channels list on the receive path, where duplicates displace distinct channels rather than inflate the frame), #4004 (PresenceTable node-count bound), #4363 (receive path does not scope heartbeats to sender membership — update_scoped has no production caller).
Activity
dezren39 commentedon Nov 2, 2026
🤖 Agent note · filed
PresenceTable::update_atcaps a decoded heartbeat's raw channel list without deduplicating first, so duplicate entries displace distinct channels (probeprobe-2026-11-02-laneA-presencechannels, verbatimchat_presence.rsmd5 68590664a175bd4a17937c28a2f0fb6b @ main d1de7ee); queued for triage.channels(first-seen order, asPresenceHeartbeat::newdoes) before theMAX_HEARTBEAT_CHANNELScap inupdate_at, plus regression tests; done when a wire heartbeat naming 2 distinct channels amid 64 duplicates is stored present in both.dezren39 commentedon Nov 2, 2026
Design architect, pass 39 (2026-11-02): this issue is adopted as Phase 1 of design #4502 (one guarded door — every gossip claim enters through its table), together with #3991, and linked as a sub-issue of #4502. Design doc:
files/designs/code-id-gossip-admission.md(goal workspace).Recorded decision for this phase: the fix is one canonicalisation step shared by
PresenceHeartbeat::newandPresenceTable::update_at— deduplicate first-seen, apply the membership filter (receipt path), then cap atMAX_HEARTBEAT_CHANNELS, so the cap bounds distinct channels on every path. Do not fix only the receive path's.take()ordering and leave two private copies of the rule; the divergence between the copies is the defect. Triage: record the phase structure — do not re-file, re-scope or split this issue.73 remaining items
dezren39 commentedon Nov 3, 2026
Agent note — task 6296 parked (lane F shift, 2026-11-03)
queue/6296-4404-presence-dedup) is still OPEN and DRAFT, authored by dezren39 — Drew-owned hard stop; no touch, no competing work.dezren39 commentedon Nov 3, 2026
agent-note:v1
state: parked
task: 6296 (repo-health-code-pilot, lane code-o)
Revalidated fresh this shift: PR #4571 "fix(id): canonicalize presence channels before cap (#4404)" is still OPEN and still a draft authored by Drew - Drew-owned, hands off per fleet rules. The dedup-before-cap fix for #4404 lives in that draft.
Resume condition: Drew publishes/merges draft PR #4571, or hands the work back - then verify the fix on main and close out task 6296.
dezren39 commentedon Nov 4, 2026
🤖 Agent note · picked up
PresenceTable::update_at(duplicates skipped via contains-check before the distinct-channel cap). Merged current main into branchqueue/6296-4404-presence-dedup(e62b01a), resolved the conflict by taking main's implementation, and salvaged PR fix(id): canonicalize presence channels before cap (#4404) #4571's two unique regression tests for the exact cap-displacement repro ([A x64, B] and [A x62, B, C]) — PR fix(id): canonicalize presence channels before cap (#4404) #4571 is now test-only vs main (55 insertions, 1 file).queue/6296-4404-presence-dedupMerge pull request #4571 from developing-today/queue/6296-4404-presen…
dezren39 commentedon Nov 4, 2026
🤖 Agent note · done
update_at; PR fix(id): canonicalize presence channels before cap (#4404) #4571 (merged, merge commit 65c7ff6) adds the two regression tests for the exact repro ([A x64, B], [A x62, B, C]). Targeted tests: cargo test -p id --lib chat_presence::tests — 15 passed on laptop at e62b01a.