# US-038 — Tracker cluster join via a single seed peer Status: planned Priority: High (blocks adding trackers without restarting the fleet) Stage: Proposed ## Context Today the tracker cluster is fully static. `RaftNode` and `NodeGossip` (`packages/tracker/meshnet_tracker/raft.py`, `gossip.py`) take a peer list at construction and never change it; `/v1/raft/status` does not expose membership. Consequences of starting a new tracker with only one existing peer in `--cluster-peers`: - Existing trackers never learn about the newcomer — they keep heartbeating and replicating to their original list only. - Quorum math (`(len(self.peers) + 1) / 2`) differs per tracker, so vote counts and commit decisions disagree — the cluster silently splits rather than erroring. Required behavior: a joining tracker is configured with **any one** live tracker (a seed). It announces itself, the membership change replicates through the Raft log, and every tracker — including the newcomer — converges on the same full peer list. Removing the need to restart existing trackers when the hive grows. ## Design 1. **Expose membership.** `GET /v1/cluster/peers` returns `{"self": , "peers": [, ...]}` (admin-safe: URLs only). 2. **Join handshake.** On startup, the joiner calls the seed's `POST /v1/cluster/join` with its public self-url. The request is hive-HMAC-signed (`MESHNET_HIVE_SECRET`, same fail-closed rule as gossip — an unauthenticated join on a public tracker would let anyone enter the hive). A non-leader seed forwards to the current leader (same pattern as `/v1/nodes/register` forwarding). 3. **Membership through the log.** The leader appends a `cluster-membership` entry (single-server change: add one peer per entry). `RaftNode` gains `apply`-side handling that swaps `self.peers` — quorum is recomputed from the applied membership, never from local config. Gossip peer list updates from the same apply hook. 4. **Joiner bootstrap.** After a successful join response (which includes the current peer list), the joiner sets its own peers and starts Raft as a follower; it catches up via normal append-entries. 5. **Persistence.** Applied membership is written to the stats/state sqlite so a restarted tracker rejoins with the last known list even if its seed is down. 6. **Config semantics.** `--cluster-peers` becomes "seed list": tried in order until one join succeeds. A tracker with no seeds and no persisted membership runs standalone (current single-tracker behavior unchanged). Out of scope: peer removal/eviction (leave via operator restart for now), joint consensus for multi-server changes, automatic seed retry after startup. ## Acceptance criteria - Start trackers A+B as a cluster; start C with only A as seed → within one election timeout, A, B, and C all report the same 3-peer membership on `GET /v1/cluster/peers`, and a value proposed on C commits on A and B - Join without a valid hive signature is rejected with 403; join to a follower is forwarded to the leader transparently - Restarting C with its seed offline rejoins from persisted membership - Standalone tracker (no seeds) behaves exactly as today - `python -m pytest` passes from repo root