# 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": <url>, "peers": [<url>, ...]}` (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
