refactor(maestro): typed perimeter advertisement replaces Option pair (NAP-272)
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>
This commit is contained in:
2026-06-01 00:32:31 +00:00
co-authored by Claude Opus 4.8
parent f3f696790c
commit eb6aeb4241
6 changed files with 93 additions and 104 deletions
+20 -27
View File
@@ -113,14 +113,13 @@ pub enum DaemonOutbound {
SessionUpdated {
session: Session,
},
/// Liveness ticker. The optional `perimeter_id` / `private_addr` advertise
/// the daemon's reachability for the T2 transport (NAP-242). Old daemons
/// that don't set them still produce the bare `{"type":"Heartbeat"}` shape.
/// Liveness ticker. The optional `perimeter` advertises the daemon's
/// reachability for the T2 transport (NAP-242); the typed object encodes the
/// "both id and addr, or neither" invariant once (NAP-272). Old daemons that
/// don't set it still produce the bare `{"type":"Heartbeat"}` shape.
Heartbeat {
#[serde(default, skip_serializing_if = "Option::is_none")]
perimeter_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_addr: Option<String>,
perimeter: Option<PerimeterCandidate>,
},
CreateSessionResult {
reply_to: ReplyTo,
@@ -522,13 +521,12 @@ mod tests {
DaemonOutbound::SessionUpdated {
session: session.clone(),
},
DaemonOutbound::Heartbeat { perimeter: None },
DaemonOutbound::Heartbeat {
perimeter_id: None,
private_addr: None,
},
DaemonOutbound::Heartbeat {
perimeter_id: Some("wg-naps62".into()),
private_addr: Some("10.0.0.7:7070".into()),
perimeter: Some(PerimeterCandidate {
perimeter_id: "wg-naps62".into(),
private_addr: "10.0.0.7:7070".into(),
}),
},
DaemonOutbound::CreateSessionResult {
reply_to,
@@ -690,12 +688,8 @@ mod tests {
let legacy = serde_json::json!({ "type": "Heartbeat" });
let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap();
match parsed {
DaemonOutbound::Heartbeat {
perimeter_id,
private_addr,
} => {
assert!(perimeter_id.is_none());
assert!(private_addr.is_none());
DaemonOutbound::Heartbeat { perimeter } => {
assert!(perimeter.is_none());
}
other => panic!("expected Heartbeat, got {other:?}"),
}
@@ -703,13 +697,10 @@ mod tests {
#[test]
fn heartbeat_empty_serializes_to_legacy_shape() {
// A new daemon that doesn't set perimeter/addr must produce wire bytes
// A new daemon that doesn't set a perimeter must produce wire bytes
// identical to an old daemon — registries doing equality checks or
// logging payloads see no surprise.
let hb = DaemonOutbound::Heartbeat {
perimeter_id: None,
private_addr: None,
};
let hb = DaemonOutbound::Heartbeat { perimeter: None };
let v = serde_json::to_value(&hb).unwrap();
assert_eq!(v, serde_json::json!({ "type": "Heartbeat" }));
}
@@ -717,13 +708,15 @@ mod tests {
#[test]
fn heartbeat_with_fields_round_trips() {
let hb = DaemonOutbound::Heartbeat {
perimeter_id: Some("wg-naps62".into()),
private_addr: Some("10.0.0.7:7070".into()),
perimeter: Some(PerimeterCandidate {
perimeter_id: "wg-naps62".into(),
private_addr: "10.0.0.7:7070".into(),
}),
};
let v = serde_json::to_value(&hb).unwrap();
assert_eq!(v["type"], "Heartbeat");
assert_eq!(v["perimeter_id"], "wg-naps62");
assert_eq!(v["private_addr"], "10.0.0.7:7070");
assert_eq!(v["perimeter"]["perimeter_id"], "wg-naps62");
assert_eq!(v["perimeter"]["private_addr"], "10.0.0.7:7070");
assert_eq!(hb, round_trip(&hb));
}