ci / rust (push) Failing after 5m58s
`(perimeter_id, private_addr)` was carried as two `Option<String>`s with an
unwritten "both Some or both None" invariant, enforced ad-hoc at every reader
and silently violable (`with_perimeter(Some, None)`).
Collapse it to `Option<PerimeterCandidate>` (reusing the existing protocol
type, so it flows unconverted from Heartbeat → registry → AttachCandidates):
- protocol: `Heartbeat { perimeter: Option<PerimeterCandidate> }` (empty still
serializes to the legacy `{"type":"Heartbeat"}` shape).
- registry: `DaemonEntry.perimeter`, `update_perimeter`, `perimeter_for` lose
the defensive AND-check; `build_attach_candidates` uses it directly.
- daemon: `RegistryClient.with_perimeter(Option<PerimeterCandidate>)`; the
both-or-neither rule now lives once, in `DaemonConfig::perimeter()`, which
warns and drops a half-configured pair instead of advertising it.
Unblocked by NAP-270's coverage; broker T2 + perimeter-mismatch e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>