feat(w7 p6): opt-in n0 DNS discovery for Discoverable presence

Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.

- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
  -> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
  publish only when Discoverable; DirectOnly (the explicit no-server posture)
  gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
  address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
  n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
  Toggling publish off drops the publisher (republish task ends; TTL-30s record
  expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
  installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
  discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
  stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
  persists it. Re-selecting Discoverable restarts the clock.

Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.

266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 16:24:46 -04:00
co-authored by Claude Opus 4.8
parent 3b02c2be0a
commit 22f0eed94d
6 changed files with 244 additions and 38 deletions
+95
View File
@@ -0,0 +1,95 @@
//! Opt-in discovery posture (W7 P6) — the pure policy that turns a (network mode,
//! "want to be Discoverable?") pair into *which* n0 DNS address-lookup services to
//! run, plus the Discoverable time-box. The actual iroh wiring (clearing and
//! reinstalling the endpoint's address-lookup services) lives in `core`; this module
//! is the small, testable decision surface so the policy can be unit-tested without
//! standing up an endpoint.
//!
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
//! address is stale and the dial falls through to discovery.
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
//! publishes their address to n0 DNS; everyone else just looks it up.
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
//! ever touches n0 there, regardless of the Discoverable toggle.
use crate::config::NetworkMode;
use std::time::Duration;
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
/// a publish-to-n0 beacon, so it auto-expires rather than standing indefinitely; the
/// user can re-toggle to restart the clock. (Open decision #3 settled: 30-minute
/// time-box, not sticky-until-off.)
pub const DISCOVERY_TIMEBOX: Duration = Duration::from_secs(30 * 60);
/// Which n0 DNS address-lookup services to install for a posture. The in-memory
/// lookup (fed by tickets + gossip, fully server-free) is always present and is
/// deliberately *not* represented here — these flags are only about n0 DNS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LookupPlan {
/// Resolve a moved friend's address by their (stable) node id via n0 DNS. A
/// passive query — publishes nothing — and only fires when a saved address is
/// stale enough that the dial falls through to discovery.
pub resolver: bool,
/// Publish *our* current address to n0 DNS so friends can find us after we moved
/// networks. The one privacy-costly bit; on only while `Discoverable`.
pub publisher: bool,
}
/// The n0 DNS services wanted for a `(network_mode, want_publish)` pair. Pure.
///
/// `want_publish` is `true` exactly when the presence posture is `Discoverable`
/// (see [`crate::presence::PresenceMode::publishes_to_discovery`]).
pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan {
match network_mode {
// The explicit serverless posture: no n0 contact at all, even to resolve.
// A Discoverable toggle here is intentionally inert.
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
// Relay-capable: always resolve (so a stationary friend can find a mover);
// publish only when the user opted into Discoverable.
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
LookupPlan { resolver: true, publisher: want_publish }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_modes_always_resolve_and_publish_only_when_wanted() {
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
assert_eq!(
lookup_plan(mode, false),
LookupPlan { resolver: true, publisher: false },
"{mode:?}: resolve always on, no publish when not Discoverable"
);
assert_eq!(
lookup_plan(mode, true),
LookupPlan { resolver: true, publisher: true },
"{mode:?}: Discoverable adds publish on top of resolve"
);
}
}
#[test]
fn direct_only_never_touches_n0_even_when_discoverable() {
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, false),
LookupPlan { resolver: false, publisher: false }
);
// The serverless posture overrides the Discoverable request entirely.
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, true),
LookupPlan { resolver: false, publisher: false }
);
}
#[test]
fn timebox_is_thirty_minutes() {
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
}
}