# 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 profile identifier above records the accepted v0.5.0 boundary. ADR 0013
supersedes the active runtime selection with
`xgrids.lixelkity-k1.fw-3.0.2.local-network.v2`; it does not change the command
dialogue or retroactively broaden this acceptance evidence.

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 13–14 are the post-initialization DeviceInfo/ModelingStatus
   refresh. Operation 12 is response-free in both retained captures; the sole
   later ModelingStatus response follows and belongs to required operation 14.
   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, protocol-reported
   unbound READY, then local stream quiescence/evidence sealing and independent
   native-project verification. Stable green is useful physical corroboration,
   not a second operator acknowledgement in the application lifecycle.

## 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 four-window response-barrier orchestrator. The initial
DeviceInfo response binds identity; ordinals 2–6 then remain ordered in one
window so ordinal 3 does not create a barrier absent from the captures. 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` defines the legacy dormant shadow boundary. An
operator-confirmed arm may retain authority for 15–300 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. The prior plugin v0.4.0 exposed 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 reviewed
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 15–120 second single-action permit backed by
five explicit operator confirmations. After the failed 2026-07-18 physical
attempt exposed topic-only response routing and an invented ordinal-3 barrier, the collapsed
bootstrap entry point and standalone START/STOP were disabled. Their staged
replacement, installed in plugin v0.5.0, binds requests 1–6 to connection, request 7 to scan-workspace entry,
requests 8–10 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 13–14 are gated by bound `SCANNING`, project presence and
`init_ready`, not by the observed 24–25-second duration. The same owner services
MQTT through explicit STOP and waits for live unbound READY. 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.

Plugin v0.5.0 installs this boundary through
`application_session.py`. One background owner is the only thread allowed to
touch the control MQTT client. The standard UI turns one explicit launch intent
into response-gated REST actions for session open (1–6), workspace entry (7),
project preparation (8–10) and START. This is control compression, not protocol
compression: each next action waits for the exact server/device phase, and
local polling never retries or times a K1 write. STOP remains a separate explicit
operator action; the subsequent READY/unbound transition completes without a
second click. Preparing local reception happens before the START event is released.
After STOP, local capture remains owned until that live protocol state. Page close, model switching,
polling and service state reads cannot emit START/STOP; model switching is
blocked while the socket is open. Repository tests replace the transport with
in-memory fakes and perform no equipment I/O.

`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 installed,
explicitly staged 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 protocol-reported unbound READY and record stable green as physical
   corroboration;
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.
