@startuml hb_failure_evict title HeartbeatFailure → evictPeer → TriggerConsensus ou DHT replenish participant "Node A" as NodeA participant "Indexer X\n(défaillant)" as IX participant "Indexer Y\n(voter)" as IY participant "Indexer Z\n(voter)" as IZ participant "DHT" as DHT participant "Indexer NEW\n(candidat)" as INEW note over NodeA: SendHeartbeat tick — Indexer X dans le pool NodeA -> IX: stream.Encode(Heartbeat{...}) IX -->x NodeA: timeout / transport error NodeA -> NodeA: HeartbeatFailure(h, proto, dir, addr_X, info_X, isIndexerHB=true, maxPool) NodeA -> NodeA: evictPeer(dir, addr_X, id_X, proto)\n→ Streams.Delete(proto, &id_X)\n→ DeleteAddr(addr_X)\n→ DeleteScore(addr_X)\n→ voters = remaining AddrInfos NodeA -> NodeA: poolSize = len(dir.GetAddrs()) alt poolSize == 0 NodeA -> NodeA: reconnectToSeeds()\n→ réinjecte IndexerAddresses (IsSeed=true) alt seeds ajoutés NodeA -> NodeA: need = maxPool\nNudgeIt() → tick immédiat else aucun seed configuré ou seeds injoignables NodeA -> NodeA: go retryUntilSeedResponds()\n(backoff 10s→5min, panic si IndexerAddresses vide) end else poolSize > 0 AND len(voters) > 0 NodeA -> NodeA: go TriggerConsensus(h, voters, need) NodeA -> IY: stream GET → GetValue{Key: candidate_DID} IY --> NodeA: GetResponse{Found, Records} NodeA -> IZ: stream GET → GetValue{Key: candidate_DID} IZ --> NodeA: GetResponse{Found, Records} note over NodeA: Quorum check:\nfound=true AND lastSeen ≤ 2×interval\nAND lastScore ≥ 30\n→ majorité → admission INEW NodeA -> NodeA: Indexers.SetAddr(addr_NEW, &INEW_AddrInfo)\nIndexers.SetScore(addr_NEW, Score{IsSeed:false})\nNudgeIt() else poolSize > 0 AND len(voters) == 0 NodeA -> DHT: go replenishIndexersFromDHT(h, need)\nDiscoverIndexersFromDHT → SelectByFillRate\n→ add to Indexers Directory end @enduml