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.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user