From 35722529bfded89adab12136d113713df51643bf Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 18:02:41 -0400 Subject: [PATCH] cog-ha-matter (ADR-116 P4): pure mDNS service-record builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens P4 with the smallest extractable unit: a pure builder that produces the wire-format `MdnsService` the responder will publish next iter. Splitting the record-builder from the responder lets us: * lock the TXT-record surface with named unit tests so drift between the cog and the HA-side YAML auto-discovery binding fires a test instead of silently breaking deployments, * swap the responder library (mdns-sd / zeroconf / pnet) without touching content, * include the advertisement in `--print-manifest` for Seed integration tests that can't boot tokio. TXT surface (sorted, RFC 6763): | cog_id | "ha-matter" | | cog_version | CARGO_PKG_VERSION | | node_id | identity.node_id | | mqtt_port | u16 stringified | | privacy | "1" | "0" | | proto | "ruview-ha/1" | 9 new tests: * service_type locked to `_ruview-ha._tcp` * instance_name carries node_id * control_port advertises the *control plane*, not MQTT * privacy flag is "1"/"0" (HA config flow reads it byte-stable) * proto version locked to ruview-ha/1 (bump is deliberate) * cog_id in TXT matches crate constant * txt_records sorted for byte-stable mDNS responses * **PII leak guard**: TXT must NOT carry hr_bpm, br_bpm, pose_*, keypoint, ssid, lat, lon, mac, rssi — broadcasts in cleartext so a future "let's add hr_bpm for convenience" patch fires here, not in a privacy incident. * required-keys lock — adding is fine, removing/renaming breaks every deployed Seed. 21/21 cog tests green (12 → 21). ADR-116 P4 flipped pending → in progress, with the responder / embedded broker / witness chain enumerated as the remaining P4 sub-units. Co-Authored-By: claude-flow --- docs/adr/ADR-116-cog-ha-matter-seed.md | 2 +- v2/crates/cog-ha-matter/src/lib.rs | 1 + v2/crates/cog-ha-matter/src/mdns.rs | 217 +++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 v2/crates/cog-ha-matter/src/mdns.rs diff --git a/docs/adr/ADR-116-cog-ha-matter-seed.md b/docs/adr/ADR-116-cog-ha-matter-seed.md index de7c6511..23eef99f 100644 --- a/docs/adr/ADR-116-cog-ha-matter-seed.md +++ b/docs/adr/ADR-116-cog-ha-matter-seed.md @@ -95,7 +95,7 @@ Ranked by build cost × user impact: | **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked | | **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done** — `cargo check` + `cargo test` green | | **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done** — `main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. | -| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | pending | +| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — mDNS service-record builder shipped: pure `mdns::build_mdns_service` returns wire-format `MdnsService { service_type, instance_name, control_port, txt_records }` with locked 6-key TXT surface (`cog_id`, `cog_version`, `node_id`, `mqtt_port`, `privacy`, `proto`). 9 unit tests, including PII-leak guard. Responder (mdns-sd) + embedded rumqttd + witness chain still pending. | | **P5** | RuVector-backed threshold learning (SONA adaptation) | pending | | **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending | | **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending | diff --git a/v2/crates/cog-ha-matter/src/lib.rs b/v2/crates/cog-ha-matter/src/lib.rs index 020be896..2f642f8b 100644 --- a/v2/crates/cog-ha-matter/src/lib.rs +++ b/v2/crates/cog-ha-matter/src/lib.rs @@ -27,6 +27,7 @@ //! discipline rules (see `docs/ADR-110-BRANCH-STATE.md`). pub mod manifest; +pub mod mdns; pub mod runtime; /// Cog identifier used in Seed's app-registry.json + the manifest. diff --git a/v2/crates/cog-ha-matter/src/mdns.rs b/v2/crates/cog-ha-matter/src/mdns.rs new file mode 100644 index 00000000..232ded4a --- /dev/null +++ b/v2/crates/cog-ha-matter/src/mdns.rs @@ -0,0 +1,217 @@ +//! `mdns` — pure builder for the cog's mDNS advertisement record. +//! +//! ADR-116 §2.2: the cog must advertise itself as `_ruview-ha._tcp` +//! so HA's discovery integration finds the Seed without manual +//! `broker host` config. This module produces the typed wire-format +//! shape — no socket I/O, no responder. The actual mDNS responder +//! (mdns-sd / zeroconf / pnet) lands next iter and consumes this +//! struct as its single input. +//! +//! Keeping the record-builder pure means: +//! +//! * the responder library can be swapped without touching the +//! content of the advertisement; +//! * the build-time `--print-manifest` path can include the +//! advertisement shape so Seed integration tests can assert on +//! it without booting tokio; +//! * the TXT keys are locked by named unit tests — drift between +//! the cog and the HA-side YAML auto-discovery (`hass-wifi-...`) +//! fires a test instead of silently breaking a deployment. +//! +//! ## TXT record convention (RFC 6763) +//! +//! HA's mDNS discovery integration reads TXT records when binding a +//! manifest to a `homeassistant.` zeroconf hook. We +//! publish the minimum set that lets HA distinguish a Seed cog from +//! a bare sensing-server and pick the right config flow: +//! +//! | Key | Value | Purpose | +//! |---|---|---| +//! | `cog_id` | `"ha-matter"` | Disambiguates from other RuView cogs | +//! | `cog_version` | `CARGO_PKG_VERSION` | HA Repairs surfaces upgrade nudges | +//! | `node_id` | identity node id | HA device registry key | +//! | `mqtt_port` | u16 string | Tells HA where to reach the cog's MQTT broker (embedded or external) | +//! | `privacy` | `"1"` / `"0"` | If `1`, HA's config flow gates biometric entities by default | +//! | `proto` | `"ruview-ha/1"` | Protocol version — bumps on breaking auto-discovery changes | +//! +//! No biometric data, no node coordinates, no SSID — TXT records +//! are broadcast in cleartext and harvested by passive scanners, so +//! treating them as PII-clean is part of the privacy posture. + +use crate::COG_ID; + +/// Default mDNS instance name template. `{node_id}` is substituted +/// at build time. Visible in HA's UI when the integration card is +/// added — "Cognitum Seed (kitchen)" beats a raw UUID. +const INSTANCE_TEMPLATE: &str = "Cognitum Seed — {node_id}"; + +/// Wire-format twin of the mDNS service record this cog publishes. +/// Owned so the responder can move the whole thing into its task. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MdnsService { + /// RFC 6763 service type. Locked to `_ruview-ha._tcp` by a named + /// test — drift breaks HA's YAML auto-discovery binding. + pub service_type: String, + /// Human-readable instance name shown in HA's discovery UI. + pub instance_name: String, + /// Port the cog's control plane listens on (NOT the MQTT broker + /// port — HA needs both, but the service record advertises the + /// control plane; the MQTT port rides as a TXT record). + pub control_port: u16, + /// TXT records sorted by key for deterministic ordering. RFC + /// 6763 §6.4 makes ordering implementation-defined, but locking + /// it keeps the cog's wire shape byte-stable across rebuilds. + pub txt_records: Vec<(String, String)>, +} + +impl MdnsService { + /// Look up a TXT key without iterating the caller. `None` if the + /// key isn't published — the responder treats absence as + /// "feature off" rather than "unknown". + pub fn txt(&self, key: &str) -> Option<&str> { + self.txt_records + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } +} + +/// Build the cog's mDNS advertisement record from the cog's typed +/// identity + ports. Pure: no I/O, no env reads. +pub fn build_mdns_service( + identity: &crate::runtime::CogIdentity, + control_port: u16, + mqtt_port: u16, + privacy_mode: bool, +) -> MdnsService { + let mut txt_records = vec![ + ("cog_id".to_string(), COG_ID.to_string()), + ("cog_version".to_string(), identity.sw_version.clone()), + ("node_id".to_string(), identity.node_id.clone()), + ("mqtt_port".to_string(), mqtt_port.to_string()), + ( + "privacy".to_string(), + if privacy_mode { "1" } else { "0" }.to_string(), + ), + ("proto".to_string(), "ruview-ha/1".to_string()), + ]; + // Deterministic ordering — see field docstring. + txt_records.sort(); + + MdnsService { + service_type: crate::MDNS_SERVICE_TYPE.to_string(), + instance_name: INSTANCE_TEMPLATE.replace("{node_id}", &identity.node_id), + control_port, + txt_records, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::CogIdentity; + + fn id() -> CogIdentity { + CogIdentity { + node_id: "kitchen-seed".into(), + friendly_name: "Kitchen Seed".into(), + sw_version: "0.3.0".into(), + } + } + + #[test] + fn service_type_locked_to_ruview_ha_tcp() { + // Drift here breaks HA's YAML auto-discovery binding. Lock + // it so a future rename surfaces a named test instead of a + // silent broken deployment. + let svc = build_mdns_service(&id(), 9180, 1883, false); + assert_eq!(svc.service_type, "_ruview-ha._tcp"); + assert_eq!(svc.service_type, crate::MDNS_SERVICE_TYPE); + } + + #[test] + fn instance_name_carries_node_id() { + let svc = build_mdns_service(&id(), 9180, 1883, false); + assert!(svc.instance_name.contains("kitchen-seed")); + } + + #[test] + fn control_port_field_holds_control_not_mqtt_port() { + // Easy to swap by accident. Lock the binding so a refactor + // doesn't silently advertise the MQTT broker as the control + // plane. + let svc = build_mdns_service(&id(), 9180, 1883, false); + assert_eq!(svc.control_port, 9180); + assert_eq!(svc.txt("mqtt_port"), Some("1883")); + } + + #[test] + fn privacy_flag_is_one_or_zero() { + let on = build_mdns_service(&id(), 9180, 1883, true); + let off = build_mdns_service(&id(), 9180, 1883, false); + assert_eq!(on.txt("privacy"), Some("1")); + assert_eq!(off.txt("privacy"), Some("0")); + } + + #[test] + fn proto_version_bumps_surface_in_txt() { + // Locked so a future breaking-change in the cog ↔ HA YAML + // contract surfaces here. Bumping it is a deliberate act. + let svc = build_mdns_service(&id(), 9180, 1883, false); + assert_eq!(svc.txt("proto"), Some("ruview-ha/1")); + } + + #[test] + fn cog_id_in_txt_matches_crate_constant() { + let svc = build_mdns_service(&id(), 9180, 1883, false); + assert_eq!(svc.txt("cog_id"), Some(crate::COG_ID)); + } + + #[test] + fn txt_records_are_sorted_for_byte_stable_advertisement() { + let svc = build_mdns_service(&id(), 9180, 1883, false); + let keys: Vec<&str> = svc.txt_records.iter().map(|(k, _)| k.as_str()).collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + } + + #[test] + fn txt_carries_no_biometric_or_pii_keys() { + // TXT records broadcast in cleartext; passive scanners + // harvest them. Lock the publishable surface so a future + // "let's add hr_bpm to TXT for convenience" patch fires a + // named test instead of leaking biometrics. + let svc = build_mdns_service(&id(), 9180, 1883, false); + let forbidden = [ + "hr_bpm", + "br_bpm", + "pose_x", + "pose_y", + "keypoint", + "ssid", + "lat", + "lon", + "mac", + "rssi", + ]; + for key in forbidden { + assert!( + svc.txt(key).is_none(), + "TXT key `{key}` leaks PII / biometric data — must not be advertised" + ); + } + } + + #[test] + fn txt_keys_match_locked_surface() { + // The HA-side YAML auto-discovery binds on these exact keys. + // Adding a key is fine; removing or renaming one breaks + // every deployed Seed. This test catches both directions. + let svc = build_mdns_service(&id(), 9180, 1883, true); + let required = ["cog_id", "cog_version", "node_id", "mqtt_port", "privacy", "proto"]; + for key in required { + assert!(svc.txt(key).is_some(), "TXT key `{key}` missing"); + } + } +}