Files
NODEDC_MISSION_CORE/docs/adr/0014-k1-macos-association-observer.md
T
DCCONSTRUCTIONS 0ca7316a24 wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
2026-08-14 14:57:50 +03:00

178 lines
8.5 KiB
Markdown

# 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.