NODEDC_MISSION_CORE/docs/adr/0012-device-bound-k1-comman...

9.5 KiB
Raw Blame History

ADR 0012: live-bound K1 control with application-level authority

  • Status: accepted
  • Date: 2026-07-18
  • Extends: ADR 0004, ADR 0005, ADR 0009 and ADR 0010

Context

Owner-controlled LixelGO traffic proves the K1 preparation and modeling dialogue for one activated K1 running the exact xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1 profile. Offline regression proves that Mission Core reproduces all ten requests before START, plus START and STOP, byte-for-byte. This describes the current device dialogue but does not authorize writes to any K1.

The protocol exposes identities with different ownership. Mission Core's inventory UUID and the macOS CoreBluetooth UUID are local transport identities. The vendor device ID and serial identify the live scanner. The OpenAPI value is private application material: retained traffic uses one value across every request type, and decompiled LixelGO uses its embedded application value before the serial-derived fallback. It is therefore not a manual per-scanner profile.

The selected BLE peripheral returns the LAN address of that same unit during the reviewed Wi-Fi provisioning exchange. On MQTT, LixelGO first sends DeviceInfoRequest without a device ID. DeviceInfoResponse supplies vendor ID, serial, model, activation and version facts; subsequent headers bind to that identity. Native project durability remains separate from command acceptance.

Decision

K1 application control is exact-profile, live-bound and fail-closed.

  1. Mission Core never substitutes its inventory UUID, BLE UUID or serial for the vendor request identity.
  2. One private application-level OpenAPI authority is loaded outside Git, Ops, browser state, APIs and logs. It is not copied into scanner profiles.
  3. The BLE-selected transport is followed by an unbound DeviceInfo exchange. Its live vendor ID, serial, activation and FW 3.0.2 facts form the transient device binding; a saved profile is optional metadata, not protocol authority.
  4. The complete retained START lifecycle keeps its 14-operation order, stage boundaries and one continuous control MQTT session. The first ten operations are pre-START; operation 11 is START; operation 12 is a bound ModelingStatus read; operations 1314 are the post-initialization DeviceInfo/ModelingStatus refresh. The only preparatory mutation is time/timezone sync. No stage is collapsed, skipped or automatically retried. Captured wall-clock gaps are operator/UI timing, not protocol delays. Stage progression uses explicit UI actions and live K1 state reports.
  5. Live DeviceInfo binding must agree with the live DeviceStatus stream before START/STOP planning. Identity drift, malformed data, inactive equipment, profile mismatch or an unexpected lifecycle state fails closed.
  6. START retains record-and-calculate mode 2, LCC mode 1, handheld mount 0 and the validated project name. STOP contains only bound header and action.
  7. MQTT application semantics remain QoS 2, retain false. An unknown outcome is reconciled from correlated response, status and physical evidence before any new operator-authorized attempt.
  8. No firmware, activation, account, update or vendor-cloud mutation belongs to this path. GetCloudServerConfig is a local K1 read.
  9. STOP completion has separate gates: correlated result, local stream quiescence/evidence sealing, READY plus steady green, then independent native project verification.

Recovered START sequence

The clean cycle contains:

  1. unbound DeviceInfoRequest;
  2. unbound ModelingStatusRequest;
  3. unbound GetRtkAdvanceRequest;
  4. bound DeviceConfigRequest time/timezone sync;
  5. bound DeviceInfoRequest;
  6. bound GetRtkAdvanceRequest;
  7. bound GetNtripProfileRequest;
  8. bound GetCloudServerConfigRequest;
  9. bound GetRtkAdvanceRequest;
  10. bound DeviceInfoRequest;
  11. bound ModelingRequest START.
  12. bound ModelingStatusRequest;
  13. bound DeviceInfoRequest after static initialization;
  14. bound ModelingStatusRequest after static initialization.

Normal sessions are ${device_id_or_empty}:${MessageType}. Time sync uses the captured special relation ${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime.

Current implementation boundary

application_bootstrap.py provides DeviceInfo decoding, the exact ten-request shadow plan and a five-batch response-barrier orchestrator. Its private retained regression completes with 10/10 request payload/topic matches and correlated required responses. ModelingStatusRequest remains non-blocking exactly where the capture had no synchronous response. application_authority.py reads the exact 36-byte authority only from a fixed macOS Keychain item and offers no plaintext fallback. LiveModelingControlSafety independently binds DeviceInfo identity to live status and creates non-executable START/STOP plans.

Public state contains only booleans, counts and wire metadata. Private identity, authority and payload bytes stay out of repr/API output. application_publish.py defines reviewed one-shot envelopes plus a current write-disabled publisher type. That type has no enable switch and raises before its injected transport sink can be called. Both shadow paths remain blocked by vendor-writes-disabled and publisher-not-installed; no MQTT publisher or automatic retry path exists.

application_execution.py adds the dormant facade boundary. An operator-confirmed arm may retain authority for 15300 seconds in a non-exportable process-memory lease and construct the shadow orchestrator. The lease and orchestrator are dropped together on expiry/disarm and also before reprovision, acquisition preparation or shutdown. Plugin v0.4.0 exposes only shadow arm/disarm/state actions and redacted snapshots. The coordinator has no request-emission method and the installed sink is explicitly uninstalled; this runtime wiring does not widen write authority.

application_mqtt.py and application_acceptance.py provide the next, still uninstalled physical-acceptance boundary. The MQTT type repeats the retained 3.1.1 session (clean_session=false, keepalive 60), subscribes only to exact known response topics, admits only reviewed request topics and QoS2/retain-false envelopes, and consumes explicit bootstrap ordinals/modeling actions before the first publish call. It performs one connect attempt with no reconnect. PUBCOMP and every required application response are separate barriers; stale, duplicate, unexpected, oversized or timed-out responses poison the transport. An unknown post-publish outcome can only be reconciled physically/status-wise; the same operation key can never be attempted again by that transport.

The acceptance executor requires a 15120 second single-action permit backed by five explicit operator confirmations. After the failed 2026-07-18 physical attempt proved that five response batches alone are insufficient, the collapsed bootstrap entry point and standalone START/STOP were disabled. The uninstalled replacement binds requests 16 to connection, request 7 to scan-workspace entry, requests 810 to project-prompt entry and START to a separate operator permit. There is no separate project-name mutation; the validated name is carried by START. Operations 1314 are gated by bound SCANNING, project presence and init_ready, not by the observed 2425-second duration. The same owner services MQTT through explicit STOP and waits for live READY plus operator-confirmed standby. Fault/status observations are redacted and any fault forbids automatic continuation. The socket remains serviced during operator/UI waits, and each short action permit is created at its button confirmation rather than at connection time. These types are synthetic-test-only, not imported by the facade, declared as plugin actions or represented by UI controls.

MacOSKeychainApplicationAuthorityProvisioner invokes Apple's interactive security add-generic-password ... -w prompt with -w last. The private value therefore never enters Mission Core argv, environment, files, logs or browser state, and the resulting item is immediately validated by the fixed loader. The repository CLI exposes this as an explicit one-time admin command, but the command has not been executed in this milestone.

Promotion gate

Promotion now requires operator-present physical acceptance of the uninstalled transport:

  1. provision the reviewed private authority into the fixed local Keychain item;
  2. confirm battery/storage and select one K1 over BLE;
  3. run the exact staged response-gated preparation and attest activated FW 3.0.2;
  4. confirm READY identity against DeviceInfo and review shadow metadata;
  5. send one START without retry and correlate its response;
  6. preserve the same control MQTT connection, issue the retained post-START reads and observe calibration plus first point/pose/camera data;
  7. send one STOP without retry and correlate its response;
  8. seal local evidence while accepting the bounded stream tail;
  9. wait for READY and steady green;
  10. verify the native project through the vendor-supported workflow.

Any unknown response, identity change, fault, low battery/storage or unexpected transition pauses the test. It never triggers a guessed recovery command.

Consequences

Adding another identical K1 means selecting it over BLE and deriving a fresh live DeviceInfo binding, not creating or copying a command profile. A second physical K1 remains a useful portability acceptance, but another packet capture is not required before implementing the reviewed current profile. The design preserves native record-and-calculate behavior while keeping vendor control in the XGRIDS plugin and out of generic Mission Core.