Skip to content

id(chat-presence): update_at caps the raw channel list without dedup — duplicate entries displace distinct channels a heartbeat genuinely claims #4404

Closed

Description

@dezren39

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) deduplicates channels into unique first, and only then truncates to MAX_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 through new), 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/ (verbatim chat_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 by update_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 → B Live, C Offline; the constructor with the same list keeps all 3.

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_at deduplicates a decoded heartbeat's channels (preserving first-seen order, as new does) before the MAX_HEARTBEAT_CHANNELS cap 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

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/lowNice to have; do when convenient
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Nov 2, 2026
  2. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a run 2026-11-02 09:05 CST
    • Found via: filed this run
    • Task: Filed after verifying on the laptop that PresenceTable::update_at caps a decoded heartbeat's raw channel list without deduplicating first, so duplicate entries displace distinct channels (probe probe-2026-11-02-laneA-presencechannels, verbatim chat_presence.rs md5 68590664a175bd4a17937c28a2f0fb6b @ main d1de7ee); queued for triage.
    • Expectation: effort/xs — dedupe channels (first-seen order, as PresenceHeartbeat::new does) before the MAX_HEARTBEAT_CHANNELS cap in update_at, plus regression tests; done when a wire heartbeat naming 2 distinct channels amid 64 duplicates is stored present in both.
    • Links: queue packet to be enqueued this run (repo-health-code-pilot)
  3. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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::new and PresenceTable::update_at — deduplicate first-seen, apply the membership filter (receipt path), then cap at MAX_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.

  4. added
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 2, 2026
  5. 73 remaining items

  6. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    Agent note — task 6296 parked (lane F shift, 2026-11-03)

  7. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    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.

  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 4, 2026
  9. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  10. added a commit that references this issue on Nov 4, 2026
  11. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions