Repository navigation
Description
Design doc: files/designs/code-id-gossip-admission.md in the keep-the-four-repos-healthy goal workspace. Audit at origin/main 381fecb60 — every defect below re-verified present at the cited sites this pass (2026-11-02); chat_presence.rs md5 at audit HEAD matches the md5 in the origins' laptop probes.
Summary
pkgs/id's gossip plane carries two kinds of self-signed claim — presence heartbeats (chat_presence.rs, #2809) and peer announcements (crates/id-core/src/discovery.rs) — and each kind is supposed to be admitted through a table whose guards canonicalise and bound it. The discovery module states the principle outright (on PeerDiscovery::update): the freshness and ordering checks live in the table "so every caller gets them (the serve.rs receive loop needs no trust logic of its own)". Presence never got the same treatment, and even discovery has one caller that bypasses its table. Five filings are one defect with five addresses:
- Constructor and receive path canonicalise differently —
PresenceHeartbeat::new(chat_presence.rs:110–124) deduplicateschannelsfirst-seen, then caps atMAX_HEARTBEAT_CHANNELS(64), so the cap bounds distinct channels.PresenceTable::update_at(lines 273–285) — the path wire heartbeats take, never passing throughnew— applies.filter(is_member).take(64)to the raw list with no dedup: 64 repeats of one channel displace a second, genuinely claimed channel (id(chat-presence): update_at caps the raw channel list without dedup — duplicate entries displace distinct channels a heartbeat genuinely claims #4404 — the fourth presence filing, the trigger pass 38 recorded for originating this design). typingis bounded by nothing on either path (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) — documented as a subset ofchannels, it is only filtered tochannels, never deduplicated or capped: one channel yields 100,000 typing entries and a ~3.3 MB signed frame, defeatingMAX_HEARTBEAT_CHANNELS' stated purpose ("bound the frame size and the work a single received heartbeat can make the table do").- The table is unbounded in nodes (id(chat-presence): PresenceTable has no node-count bound — every distinct self-signed key is stored for a full TTL, so minted keys grow serve's memory without limit #4004) —
PresenceTableis aHashMapwith no capacity; every signature-valid heartbeat inserts, keys are free to mint, and each fresh entry is guaranteed a fullPRESENCE_TTL_SECS(75 s) of retention. The sibling discovery table solved this exact shape:MAX_PEERS = 1_000with stale-only eviction (discovery.rs:331, 501–515). - Receipt scoping exists, is tested, and is never wired (id(chat-presence): receipt never scopes heartbeats to sender membership — update_scoped has no production caller, serve stores channels unscoped #4363) —
PresenceTable::update_scopedhas no caller outside its own module; the production receipt path inserve(recv_presence.update(heartbeat), line ~1937) stores channels unscoped, while the module doc (chat_presence.rs:24–31) states the scoping as a present-tense property. The read side kept its half (roster_for_viewer_at); signature verification does not substitute — a node signs its own heartbeat, so it stops forgery of another node's claim, not claims to channels the signer does not belong to. - The peers collector bypasses the guarded table entirely (id(peers): --gossip collection bypasses the announcement freshness/ordering guards — replayed, ancient and far-future announcements are reported as discovered peers #4377) —
discover_via_gossip(commands/peers.rs:453–462) collects into a rawHashMap, inserting every decodable announcement unconditionally (timestamp_secsnever read): a 1970 replay, au64::MAXstamp, an older-after-newer regression all collected — andcmd_peersmerges gossip results after RPC results, so a stale gossip copy overwrites a fresher RPC entry. The failure id(discovery): peer announcements carry a timestamp nothing checks — a replayed or out-of-order announcement overwrites newer peer state and can keep a departed peer 'live' forever #1552 closed forserve, still live in the CLI.
The contract
A gossip claim is stored only in the form its table's guards produce — canonical, scoped, and bounded — and every ingest path, constructor, receiver, and one-shot collector alike, enters through those guards. There is no second door.
Recorded decisions (full reasoning, alternatives and risks in the design doc):
- Lists are sets, capped by distinct count, canonicalised once at admission — one canonicalisation step shared by
PresenceHeartbeat::newandPresenceTable::update_at: deduplicate first-seen, apply the membership filter (receipt path), then cap atMAX_HEARTBEAT_CHANNELS.typingis deduplicated after filtering to the storedchannels, making it a true subset:typing.len() ≤ channels.len() ≤ 64for every stored heartbeat, however it arrived. A stored heartbeat never contains duplicates in either list. (Pins the shared-helper shape neither origin fixed — two private copies of the rule are how the paths diverged.) - The presence table is bounded exactly as the discovery table is —
MAX_PRESENCE_NODES = 1_000, named besideMAX_HEARTBEAT_CHANNELS/PRESENCE_TTL_SECS, aligned withMAX_PEERS. At capacity a new node is admitted only by evicting an entry already past its TTL (oldest such, ties by key bytes — mirroringPeerDiscovery::update); if every entry is fresh, the new node is refused; an existing node always refreshes (subject to the ordering guard). This pins id(chat-presence): PresenceTable has no node-count bound — every distinct self-signed key is stored for a full TTL, so minted keys grow serve's memory without limit #4004's open eviction choice: not refuse-all-new (dead entries would lock out legitimate nodes for a full TTL), not evict-soonest-expiring (a flood could churn live entries out early); stale-only eviction means fresh minted keys displace nothing still live. - Membership scoping is wired, not documented away — id(chat-presence): receipt never scopes heartbeats to sender membership — update_scoped has no production caller, serve stores channels unscoped #4363 offers two endings (wire
update_scoped, or correct the doc); this design records wire it: the doc states the intended id(chat): presence heartbeat protocol over gossip (ephemeral, TTL-based) #2809 property, the read side already implements its half, and presence becomes load-bearing the moment the chat layer names channels — wiring receipt after that leaves a window where rosters are read from an unscoped table everyone believes is scoped. The predicate is backed by the receiver's own group fold for the channel (GroupViewmember set —servealready folds group logs in its group-warnings path); a channel the receiver holds no fold for scopes to empty (membership the receiver cannot see cannot be confirmed).run_gossip_loopgaining the membership source/predicate as a parameter is part of the phase. Sender side unchanged —serve's own heartbeat still names no channels, which is precisely why the wiring lands now. - No side doors: a collector uses the table, not a raw map —
discover_via_gossipcollects through a temporaryPeerDiscoveryand reads back its non-stale peers. id(peers): --gossip collection bypasses the announcement freshness/ordering guards — replayed, ancient and far-future announcements are reported as discovered peers #4377 leaves the shape open ("or a shared helper it uses"); this design pins through the table — re-implementing freshness/ordering inpeers.rscreates the second copy whose divergence is this design's subject (the collector's hand-rolledMAX_PEERSguard atpeers.rs:460–462copied the capacity rule and missed the other two). With both sources freshness-checked, thecmd_peersmerge order stops mattering for correctness. - The unscoped door is dispositioned, not left ambient —
PresenceTable::updateremains only if the closing audit names a production caller that legitimately needs the transport view; otherwise it is removed or made crate-private in the audit phase. A public unscoped admission path beside the scoped one is how id(chat-presence): receipt never scopes heartbeats to sender membership — update_scoped has no production caller, serve stores channels unscoped #4363 happened.
Phased breakdown
Phases adopt the five filed origins as sub-issues (no parallels); Phase 5 is a new closing-audit issue, gated on Phases 1–4.
- Phase 1 — canonical lists at admission (adopted: id(chat-presence): update_at caps the raw channel list without dedup — duplicate entries displace distinct channels a heartbeat genuinely claims #4404 + 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). The shared canonicalisation of decision 1.
- Phase 2 — bounded table (adopted: id(chat-presence): PresenceTable has no node-count bound — every distinct self-signed key is stored for a full TTL, so minted keys grow serve's memory without limit #4004).
MAX_PRESENCE_NODESwith stale-only eviction (decision 2), on every insert path. Independent of Phase 1. - Phase 3 — scoped receipt in
serve(adopted: id(chat-presence): receipt never scopes heartbeats to sender membership — update_scoped has no production caller, serve stores channels unscoped #4363).update_scopedwith the group-fold-backed predicate (decision 3) + the origin's end-to-end test. Independent in code; the phase that makes the module doc true. - Phase 4 — peers collector through its table (adopted: id(peers): --gossip collection bypasses the announcement freshness/ordering guards — replayed, ancient and far-future announcements are reported as discovered peers #4377). Collection via
PeerDiscovery(decision 4); the raw-map merge and its hand-rolled capacity guard go away. - Phase 5 — closing inventory + contract statement (new issue, gated on Phases 1–4): every frame-decode / claim-ingest site in
pkgs/iddispositioned (through its table / named exception), with required dispositions forPresenceTable::update(decision 5), the RPC crawl paths merged incmd_peers(a different trust surface — disposition each as deliberately-unguarded or guarded, with the reason), anydecode_framecaller outsideserve/peersthe grep finds, and the gossip frame-size bound above the table (which layer owns it after Phase 1 bounds stored form). Contract statement lands in thechat_presence.rsmodule doc as an admission section, cross-referenced atPeerDiscovery::update's doc.
Open questions (in the design doc, not filed)
Whether decode_frame itself should refuse oversize frames or the gossip layer's bound suffices once stored form is bounded (Phase 5 dispositions the layering); the sender-side mirror rule when the chat layer starts naming channels (that layer's own design question); a hypothetical third gossip claim kind enters through this contract, no phase filed for it.
Related
Design doc (full problem/evidence/alternatives/risks): files/designs/code-id-gossip-admission.md (goal workspace). Origins: #4404, #3991, #4004, #4363, #4377. Siblings, not duplicates: #2898 / #4000 (reconstruction is admission — the journal restore gate; this is the gossip receipt gate), #2944 (discovery table authentication/bounds — its landed MAX_PEERS rule is decision 2's embodiment), #1552 (closed — the serve announcement guards #4377's collector bypasses).
Filed by the design architect (pass 39, 2026-11-02): design issue for the pkgs/id gossip-admission contract — the pass-38 standing candidate, originated on its recorded trigger (#4404, the fourth presence/receipt filing). The five origin issues are adopted as its phases — triage of each origin should record the phase structure and the decisions above rather than re-filing or re-scoping. Phase 5's closing audit is filed separately and gated on Phases 1–4.
Acceptance criteria
- The design records, on this issue or a linked doc: the single guarded entry point every gossip claim passes through, the table it enters, and the invariants the guard enforces.
- Migration path for existing gossip claim sites in pkgs/id presence (and any sibling callers) is enumerated.
- A linked implementation issue (or native sub-issues per protocol section 11) exists before this design is treated as done — a design with no implementation link is not finished work.
Activity
dezren39 commentedon Nov 2, 2026
Design architect, pass 39 (2026-11-02): this issue is the design of record for the pkgs/id gossip-admission contract. The five origin issues are adopted as its phases and linked as sub-issues: Phase 1 = #4404 + #3991 (canonical lists at admission), Phase 2 = #4004 (bounded table), Phase 3 = #4363 (scoped receipt in serve), Phase 4 = #4377 (peers collector through its table), Phase 5 = #4503 (closing gossip-ingest inventory + contract statement, gated on Phases 1–4,
status/blocked).The five recorded decisions are in the issue body and the design doc (
files/designs/code-id-gossip-admission.md, goal workspace). This design is the pass-38 standing candidate originated on its recorded trigger: #4404 was the fourth presence/receipt filing. Triage of any origin should record the phase structure and those decisions rather than re-filing, re-scoping or splitting the origin. Register entry added tofiles/potential-projects.md(status: proposed). Nouser/review: every origin carries explicit acceptance with verified probes, and the open rulings (shared canonicalisation, theMAX_PRESENCE_NODESeviction policy, wiring scoping over doc correction, collector-through-the-table) are the architect's recorded decisions in the doc.dezren39 commentedon Nov 2, 2026
Intake review
Related: Filed by the design-architect as an epic (kind/design, app/id, source/design-architect); sibling design code issue #3456 is referenced in the follow-up chain (see code issue #4457, Phase 3b follow-up of #3456).
Notes: No priority or effort at filing; this pass sets priority/normal + effort/l (design + fleet-wide claim-path change), adds acceptance criteria and meta/enriched. Status stays needs-triage for the design review.
Risks: Guarded-door changes touch every gossip claim path; an underspecified guard risks rejecting legitimate claims at runtime. The design should pin failure behavior (reject vs queue vs drop) per claim type.
Suggested approach: Design review first (Muse review lane), then file the implementation issue(s) natively linked before any code work.
— fleet monitor intake enrichment, 2026-11-02.
dezren39 commentedon Nov 3, 2026
Program tending — sub-issue progress (2026-11-03, via #1352)
Closed since this thread's last activity (2026-11-03T00:13Z):
Progress: 3 of 6 sub-issues closed (#3991, #4363, #4377). Remaining open: #4404, #4004, #4503. Recorded for the parent rollup; this is a progress note, not a completion proposal.
Merge origin/main into bolt/presence-node-bound-4004-2430903124535921709
developing-today commentedon Nov 3, 2026
agent-note:v1
state: triaged (coverage pass)
Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 40 (priority/normal). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
Found via: coverage pass 2026-11-03
dezren39 commentedon Nov 3, 2026
Triage at current main (3e363c9), queue task 8410
Phase status, re-verified against GitHub and main:
queue/6296-4404-presence-dedup), which canonicalizes channels before the cap. Main still hasPresenceTable::update_atcapping the raw list (chat_presence.rs~L283) with no dedupe, so the fix is not yet on main. Owned by that PR, which stays draft.queue/claimedby another agent. Open PR fix(id): bound PresenceTable with stale-only eviction #4160 (bolt/presence-node-bound-4004-…, boundsPresenceTableat 1,000 nodes with stale-only eviction) is the live artifact. Not on main yet. No duplicate PR from this lane.task/triage. The design gates it on Phases 1–4, so it is blocked until id(chat-presence): update_at caps the raw channel list without dedup — duplicate entries displace distinct channels a heartbeat genuinely claims #4404 and id(chat-presence): PresenceTable has no node-count bound — every distinct self-signed key is stored for a full TTL, so minted keys grow serve's memory without limit #4004 land.Verdict: still live, but in progress. No new fix to route, because routing one now would duplicate draft PR #4571 and open PR #4160. The ordering is recorded here: Phase 1 and Phase 2 land first (via their existing PRs), then Phase 5 closes the epic. This issue stays open as the design of record. No labels added.
Found via: coverage pass 2026-11-03