# ADR 0014: long-lived macOS host-association observer Status: planned production boundary; software contract may be developed behind a disabled feature flag. Related acceptance item: `CONN-66` in [`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md). ## Context Mission Core must distinguish a K1 that is configured for a network from a Mac that is currently attached to the same network. Route, TCP, DeviceInfo, control and data evidence are bound to a host-path epoch; a Wi-Fi switch, sleep/wake cycle or observer restart must invalidate that epoch before any late TCP/MQTT result can restore command authority. The current laboratory implementation is fail-closed but not a production observer. One normal connection-monitor poll samples the host path before and after its TCP probe. Each sample synchronously invokes: ```text /usr/bin/xcrun swift plugins/xgrids-k1/macos/associate_wifi.swift ``` under one process-local lock with a 30-second timeout. At the one-second monitor interval this can launch two Swift processes per second. A failed cycle can occupy the lock for roughly sixty seconds, and cancellation of the Python `asyncio.to_thread()` waiter does not terminate the native process or thread. Physical-command validation shares this observation path. Shorter timeouts, cached shell output or automatic fallback would hide rather than remove the lifecycle defect. ## Decision Production host-association evidence will come from one signed, long-lived, read-only agent in the user's macOS login session. - The agent owns one `CWWiFiClient` for its process lifetime. - It observes CoreWLAN link/association/power events and macOS sleep/wake. - It never scans BLE, changes Wi-Fi, reads K1 credentials, reconnects MQTT or sends START/STOP. - It is packaged in a minimal container app and registered with `SMAppService`; it is not a `LaunchDaemon` and is not launched through `xcrun` at runtime. - The required Wi-Fi event entitlement and Location authorization are checked before K1 network mutation is offered. Missing authorization produces explicit unavailable evidence, not a crash loop or guessed association. - The existing Wi-Fi mutator remains a separate component under the exclusive network process lease. Observer authority and mutation authority are never combined. The backend communicates with the observer through bounded local IPC. Each backend session supplies a random HMAC key. SSID and BSSID remain inside the agent; only an opaque continuity token is returned and it cannot be correlated between backend processes. The token material is interface plus BSSID; SSID is used only to report evidence quality. This keeps one AP identity stable when macOS alternates between `ssid+bssid` and `bssid-only` disclosure. ## Observer contract ```text schema_version: missioncore.macos-host-association/v2 agent_instance_id: random 128-bit process instance sequence: uint64 association_epoch: uint64 interface_name: string | null wifi_interface: true | false | null state: associated | not-associated | inactive | not-wifi | unavailable evidence_quality: ssid+bssid | bssid-only | not-wifi | unavailable continuity_token: 64 lowercase hex | null reason_code: string | null observed_monotonic_ns: uint64 sample_age_ms: uint32 cause: initial | link-change | association-change | power-change | permission-change | will-sleep | did-wake | poll-correction | observer-restart ``` `sequence` changes for every event or heartbeat. `association_epoch` changes when interface, power, state, SSID or BSSID changes. Sleep and wake each create a barrier even if the visible network looks unchanged afterward. A new agent instance, IPC reconnect, sequence rollback/gap, malformed frame or timeout is also a discontinuity. The backend adds its own `observer_session_epoch`; the effective host-route fingerprint includes the agent instance, observer session, association epoch and opaque token. A response from an old session or sequence is discarded. Unknown schema/state or incomplete evidence is `unavailable` and immediately revokes host authority. ## Timing and failure semantics - Heartbeat: 1 second. - Maximum cached-snapshot age: 750 ms. - Snapshot RPC deadline: 250 ms. - Initial handshake deadline: 2 seconds. - Two missed heartbeats or one invalid IPC frame revoke authority immediately. - Reconnect backoff: 250 ms, 500 ms, 1 s, 2 s, then at most 5 s. - There is no automatic fallback to the Swift source runner. - Agent loss affects only read-only host evidence. It never triggers a K1 network write, MQTT reconnect or physical command. - A discontinuity first marks the supervisor host path unavailable and rotates its epoch. Recovery then requires fresh route, TCP and DeviceInfo/control evidence in that order. ## Delivery phases Phase A is safe without signing or a physical K1: 1. Define the Python observer protocol and validate the v2 schema. 2. Add a fake/in-memory transport and backend session/sequence validator. 3. Implement immediate epoch invalidation and bounded cached lookup. 4. Inject the observer into the monitor behind a disabled feature flag. 5. Implement the Swift reducer and local transport as a testable Swift package. 6. Test sleep/wake, timeout, event gaps, delayed replies, crash/restart and manual network changes. 7. Expose secret-free observer health and next action to the UI. 8. Prove 10,000 samples launch no child process and cause no lock starvation. Phase B requires the actual Mac signing and permission environment: 1. Package and register the user-session agent. 2. Obtain the Wi-Fi events entitlement and complete Location onboarding. 3. Run the observer in shadow mode beside the current fail-closed probe. 4. Cut over only after the physical fault matrix and an eight-hour soak show no unexplained divergence. ## Acceptance gate - No `xcrun`, `swift` or `swiftc` occurs on the observer path. - One agent and one CoreWLAN client serve one login session. - Snapshot p99 is below 50 ms, hard deadline 250 ms, monitor-cycle p99 below 1.5 seconds. - No mutex is held across native or IPC calls. - Sleep, wake, agent restart, sequence gap and timeout always invalidate the effective host epoch. - Late TCP/DeviceInfo evidence from an old epoch is rejected. - SSID, BSSID and credentials never enter IPC logs, API state or artifacts. - Quick-to-Bridge, Bridge-to-Quick, manual Wi-Fi switch, Wi-Fi off/on, router loss/return with the same SSID/IP, backend restart and Location denial all revoke control authority within two seconds and recover only through fresh route, TCP and DeviceInfo evidence. Until this gate passes, the current association probe remains explicitly a laboratory implementation and `CONN-66` remains open. ## Laboratory containment while Location evidence is hidden The source-runner helper can return `association-identity-unavailable` on a connected Mac when macOS privacy rules hide SSID and BSSID from the CLI child process. Rotating a random fallback token on every one-second poll made a stable route and a successful TCP probe mutually impossible: every following sample revoked the preceding endpoint result as a fictitious network switch. Until the signed observer above replaces the source runner, the laboratory probe uses one random, process-scoped token for the same interface and unavailable-evidence scope. This is not promoted to association evidence: - the public evidence quality remains `unavailable`; - interface, source address, kernel route, availability, a proven different BSSID and process restart remain epoch barriers; - endpoint reachability alone remains `configured-unverified`; - only fresh exact DeviceInfo/control evidence can grant control authority; - `CONN-66`, sleep/wake and same-subnet network-switch acceptance remain open. For an already reachable lease whose exact DeviceInfo identity and control session remain healthy, a temporary helper timeout or privacy-limited association sample may retain the preceding proven association fingerprint only while the kernel route fingerprint, interface, source, intent and target are unchanged. That retained sample still performs TCP contact and a second kernel-route check, refreshing only route/TCP observation TTLs. Endpoint loss, control loss, control-proof expiry, target/intent change, a proven association identity change or any raw route change revokes immediately. A `configured-unverified` path does not receive this bridge and remains bounded by the existing technical-failure debounce and transport TTL. This containment removes the false per-poll epoch churn observed on the field Mac without claiming that the planned production observer has been delivered.