feat(k1): recover exact application bootstrap

This commit is contained in:
DCCONSTRUCTIONS 2026-07-18 13:54:25 +03:00
parent af9319a33b
commit d7749208a7
15 changed files with 906 additions and 215 deletions

View File

@ -139,11 +139,14 @@ stopped by the verified double-click.
The exact recovered `ModelingRequest` start/stop encoder, response correlator The exact recovered `ModelingRequest` start/stop encoder, response correlator
and device-status state machine are implemented as inert protocol components. and device-status state machine are implemented as inert protocol components.
The command header derives `${device_id}:ModelingRequest` from an explicit ASCII The plugin also contains the exact ten-request pre-START bootstrap recovered
device ID and requires an explicit ASCII OpenAPI key, but the key's legitimate from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
provenance/provisioning and durable post-stop save evidence remain unresolved. the response supplies live vendor identity, serial, model, activation and
There is no MQTT publisher in this path, `vendor_writes_enabled` remains false, version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
and no modeling command is sent. as one private application-level value in the observed client, not a manual
per-scanner profile. The whole pre-START order and START/STOP payloads are
byte-matched offline, but there is no MQTT publisher, `vendor_writes_enabled`
remains false, and no application command is sent.
This locked bootstrap is repeatable in the current workspace, not yet a This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from standalone release install. The frontend consumes sibling `file:` packages from

View File

@ -21,23 +21,25 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented | | Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet | | Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | PAUSE (shadow gate implemented) — the exact captured codec is byte-matched, live status binds vendor identity plus serial, and START/STOP can be planned without publish authority; device-bound OpenAPI enrollment, one-shot publisher, physical save acceptance and recovery policy remain closed gates | | K1 application control | PAUSE (exact shadow dialogue implemented) — all 10 retained pre-START requests and START/STOP are byte-matched; BLE-selected live DeviceInfo supplies per-scanner identity while one private application-level OpenAPI authority remains outside Git/Ops; publisher, physical save acceptance and recovery policy remain closed gates |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
start/stop mapping and RTSP camera transport. The exact start/stop protobuf start/stop mapping and RTSP camera transport. The exact start/stop protobuf
encoder, response correlator and device-status state machine now exist without a encoder, response correlator and device-status state machine now exist without a
publisher. Header construction is known: an explicit ASCII device ID and ASCII publisher. Retained PCAP plus client static analysis prove that OpenAPI is one
OpenAPI key are required, while the session ID is derived exactly as private application-level value in this LixelGO build, not a per-scanner
`${device_id}:ModelingRequest`. MQTT control publishing remains deliberately credential. The selected BLE peripheral returns its own LAN IP; the initial
disabled. A live-only shadow safety layer now observes the device-reported unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
vendor identity and serial, rejects identity drift or malformed status, and can version facts for that live K1. Mission Core's shadow bootstrap reproduces the
form a non-executable START only from unbound `READY` or STOP only from a bound 10 observed pre-START requests byte-for-byte: three unbound discovery reads,
`SCANNING` state. Its output contains a digest and wire metadata, never the one response-bound time/timezone sync and six further reads. A live-only safety
device identity, serial, OpenAPI value or payload. Legitimate per-device OpenAPI layer rejects identity drift or malformed status, and can form a non-executable
provisioning, an authorized one-shot publisher and a durable post-stop save gate START only from unbound `READY` or STOP only from bound `SCANNING`. Public output
are not proven. The physical button remains the known-safe fallback. contains only digests and wire metadata. Secure application-authority loading,
an authorized response-gated one-shot publisher and durable post-stop save are
not yet proven. The physical button remains the known-safe fallback.
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. The queue holds four preview messages; queue cannot discard MQTT evidence. The queue holds four preview messages;
@ -256,7 +258,7 @@ useful stream is decoded or structurally identified.
- firmware or internal-Linux analysis; - firmware or internal-Linux analysis;
- physical end-to-end shared-timeline playback for a newly archived session - physical end-to-end shared-timeline playback for a newly archived session
containing point cloud plus one selected K1 camera; containing point cloud plus one selected K1 camera;
- legitimate K1 OpenAPI credential provisioning, authorized command transport - private application-level OpenAPI authority loading, authorized command transport
and stable-artifact proof after the observed stop lifecycle; and stable-artifact proof after the observed stop lifecycle;
- long-running large-session WebViewer/WASM memory telemetry; - long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging. - production retention, replication, encryption and cross-platform packaging.

View File

@ -166,14 +166,27 @@ separated four values which must never be substituted for one another:
belongs in a vendor request; belongs in a vendor request;
- the vendor device ID comes from live K1 status and occupies the request header; - the vendor device ID comes from live K1 status and occupies the request header;
- the K1 serial is a separate live device-binding value; - the K1 serial is a separate live device-binding value;
- the OpenAPI value is a private, device-bound command credential and is not - the OpenAPI value is private application-level material embedded by the
present in the status report. observed LixelGO build; it is shared across all retained request types and is
not derived from the current scanner while the application value is present.
Consequently, support for another K1 cannot replay this scanner's captured Consequently, support for another K1 never replays captured payload bytes and
payload or OpenAPI value. It requires a separate owner-authorized enrollment does not require a manually maintained scanner profile. BLE selection and its
whose vendor device ID and serial are attested against live status under the provisioning response bind the transport to that unit; an initial
same exact firmware/topology profile. Any mismatch, identity drift, malformed `DeviceInfoRequest` without device ID returns the live vendor ID, serial, model,
status, replay-only evidence or unexpected lifecycle state fails closed. activation and version facts. Those values bind later requests and must agree
with the live status stream. Any mismatch, identity drift, malformed response,
replay-only evidence or unexpected lifecycle state fails closed.
The clean LixelGO cycle contains exactly ten requests before START, all QoS 2
and retain false: `DeviceInfo`, `ModelingStatus`, `GetRtkAdvance`, a bound
`DeviceConfig` time/timezone sync, then `DeviceInfo`, `GetRtkAdvance`,
`GetNtripProfile`, `GetCloudServerConfig`, `GetRtkAdvance`, `DeviceInfo`. The
special time-sync session is exactly
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The
repository shadow bootstrap reproduces all ten retained payloads and their
topic order byte-for-byte. Time sync is the only mutation before START; the
other nine requests are reads.
The repository now contains an inert bounded encoder/response parser and a The repository now contains an inert bounded encoder/response parser and a
fail-closed device-status state machine for this exact profile. A live-only fail-closed device-status state machine for this exact profile. A live-only
@ -182,9 +195,9 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and
wire metadata, uses QoS 2 with retain false, and declares automatic retry wire metadata, uses QoS 2 with retain false, and declares automatic retry
forbidden. It has no MQTT publish dependency and cannot send a command. forbidden. It has no MQTT publish dependency and cannot send a command.
Publishing remains deliberately disabled: secure per-device OpenAPI enrollment Publishing remains deliberately disabled: the private application authority
is unresolved, and the complete stop/save sequence has not physically proved a still needs a secure runtime loader and the complete stop/save sequence has not
durable vendor project. A correlated STOP success, stream quiescence, return to physically proved a durable vendor project. A correlated STOP success, stream quiescence, return to
`READY`/steady-green hardware state and appearance of a reusable native project `READY`/steady-green hardware state and appearance of a reusable native project
are separate gates. The verified physical double-click remains the acquisition are separate gates. The verified physical double-click remains the acquisition
control until a separately reviewed one-shot write gate closes. control until a separately reviewed one-shot write gate closes.

View File

@ -143,14 +143,13 @@ per-message metadata and a hash summary. Repository-level
the current writer. The connector subscribes to the fixed report-topic the current writer. The connector subscribes to the fixed report-topic
allowlist and does not publish an application request or modeling command. allowlist and does not publish an application request or modeling command.
The recovered command substrate is intentionally inert. It can encode exact The recovered command substrate is intentionally inert. It byte-matches the ten
start/stop request bytes, correlate response identity/action/result and classify observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
observed device states without importing MQTT or publishing anything. The header responses, and classifies live device states without importing MQTT or
requires explicit ASCII `device_id` and OpenAPI key and derives the session ID publishing anything. One private application-level OpenAPI authority is kept
exactly as `${device_id}:ModelingRequest`. Legitimate OpenAPI key separate from the transient vendor ID/serial returned by the BLE-selected K1.
provenance/provisioning and durable save completion after stop are unresolved, Secure authority loading and durable save completion after stop remain open, so
so `vendor_writes_enabled` is false and the physical-button workflow remains `vendor_writes_enabled` is false and the physical-button workflow is canonical.
canonical.
## Automatic Rerun source and lifecycle ## Automatic Rerun source and lifecycle
@ -332,8 +331,8 @@ listener and its process memory must be closed unconditionally.
separate generic media path. Historical sessions predating that archive have separate generic media path. Historical sessions predating that archive have
no recoverable video. no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command - Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs a separately reviewed state-changing profile with legitimate publisher needs reviewed private application-authority loading, response-gated
OpenAPI key provisioning and a proven durable-save completion gate. one-shot behavior and a proven durable-save completion gate.
- No terrain map, elevation model, obstacle segmentation, localization fusion, - No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone. mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting - Exact coordinate axes and the scanner-to-vehicle transform remain a mounting

View File

@ -142,16 +142,14 @@ transport mutation. Application-command publishing remains disabled; the
separately reviewed BLE Wi-Fi provisioning write retains its own explicit separately reviewed BLE Wi-Fi provisioning write retains its own explicit
operator gate. operator gate.
An exact but inert K1 modeling-control substrate now lives inside the vendor An exact but inert K1 application-control substrate now lives inside the vendor
plugin. It encodes the recovered start/stop protobuf shapes, strictly correlates plugin. It byte-matches the retained ten-request pre-START dialogue plus
response identity/action/numeric result and maps bounded device-status values START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
into an observation-only state machine. Header construction is no longer device-status values into an observation-only state machine. The OpenAPI value
guessed: callers provide explicit ASCII device ID and ASCII OpenAPI key, and the is one private application-level authority; vendor ID and serial come from the
session ID is derived exactly as `${device_id}:ModelingRequest`. No MQTT import BLE-selected live K1 and are cross-checked against status. No MQTT publisher
or publisher exists in that substrate. Legitimate OpenAPI key exists. Secure authority loading and stable-artifact proof after STOP remain
provenance/provisioning and stable-artifact proof after the stop lifecycle are unresolved, so `vendor_writes_enabled=false`.
still unresolved, so `vendor_writes_enabled=false` and every state-machine
snapshot remains false for durable save completion.
## Semantic lifecycle ## Semantic lifecycle
@ -223,8 +221,8 @@ views.
4. Physically accept a newly archived left/right K1 session, then package the 4. Physically accept a newly archived left/right K1 session, then package the
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media caching and evolve same-host MSE delivery toward an authenticated Edge media
plane. Keep the modeling-command publisher disabled until legitimate OpenAPI plane. Keep the modeling-command publisher disabled until private application
key provisioning and durable-save confirmation close its separate safety authority loading and durable-save confirmation close its separate safety
gate. gate.
Complex equipment will likely be assembled from separately useful component Complex equipment will likely be assembled from separately useful component

View File

@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate.
No retained physical K1 session contains the new canonical camera archive, so a No retained physical K1 session contains the new canonical camera archive, so a
real point-cloud plus one-camera recorded playback remains an explicit hardware real point-cloud plus one-camera recorded playback remains an explicit hardware
acceptance test. Automated protocol tests also do not authorize K1 modeling acceptance test. Automated protocol tests also do not authorize K1 modeling
publishing: legitimate OpenAPI key provisioning and durable save completion publishing: private application-authority loading and durable save completion
remain separate physical/security gates. remain separate physical/security gates.

View File

@ -112,9 +112,9 @@ plugin-owned.
The operator project name is normalized and validated by the K1 contribution, The operator project name is normalized and validated by the K1 contribution,
stored as display/catalog metadata and never used as a path component. This ADR stored as display/catalog metadata and never used as a path component. This ADR
does not claim that it reaches the scanner: the inert modeling-control codec has does not claim that it reaches the scanner: the inert application-control codec
no publisher, and automatic K1 writes remain disabled pending legitimate OpenAPI has no publisher, and automatic K1 writes remain disabled pending reviewed
credential provisioning and durable post-stop save evidence. private application-authority loading and durable post-stop save evidence.
ADR 0011 subsequently places the action control plane behind a versioned ADR 0011 subsequently places the action control plane behind a versioned
descriptor/handshake/health transport seam. Observation discovery and export descriptor/handshake/health transport seam. Observation discovery and export

View File

@ -1,4 +1,4 @@
# ADR 0012: device-bound K1 command authority # ADR 0012: live-bound K1 control with application-level authority
- Status: accepted - Status: accepted
- Date: 2026-07-18 - Date: 2026-07-18
@ -6,89 +6,113 @@
## Context ## Context
Owner-controlled LixelGO traffic proves the K1 modeling START/STOP protobuf Owner-controlled LixelGO traffic proves the K1 preparation and modeling
shape, MQTT topic, QoS and correlated success response for one K1 running the dialogue for one activated K1 running the exact
exact `xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. A bounded offline `xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. Offline regression proves
audit also proves that Mission Core's encoder reproduces both retained requests that Mission Core reproduces all ten requests before START, plus START and STOP,
byte-for-byte. This is sufficient to describe the current device dialogue, but byte-for-byte. This describes the current device dialogue but does not authorize
not to replay a captured request or authorize writes to any K1. writes to any K1.
The protocol exposes several identities with different authority. Mission The protocol exposes identities with different ownership. Mission Core's
Core's provisional device UUID is local inventory identity. The vendor device inventory UUID and the macOS CoreBluetooth UUID are local transport identities.
ID is reported by the live K1 and occupies the request header. The K1 serial is The vendor device ID and serial identify the live scanner. The OpenAPI value is
a separate device-binding value. The OpenAPI value is private command material private application material: retained traffic uses one value across every
which is absent from live status. LixelGO derives the command session relation request type, and decompiled LixelGO uses its embedded application value before
from the vendor device ID and request type; it is not a reusable caller session. the serial-derived fallback. It is therefore not a manual per-scanner profile.
Native project durability is also not equivalent to command acceptance. STOP The selected BLE peripheral returns the LAN address of that same unit during
success, stream quiescence, return to READY/steady green and a reusable project the reviewed Wi-Fi provisioning exchange. On MQTT, LixelGO first sends
on the scanner are separate observations. `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 ## Decision
K1 application control is exact-profile, device-bound and fail-closed. K1 application control is exact-profile, live-bound and fail-closed.
1. Mission Core never sends its local inventory UUID, BLE identifier or a K1 1. Mission Core never substitutes its inventory UUID, BLE UUID or serial for
serial in place of the vendor request identity. the vendor request identity.
2. Each K1 requires an owner-authorized enrollment containing its vendor device 2. One private application-level OpenAPI authority is loaded outside Git, Ops,
ID, serial and private OpenAPI value. Enrollment secrets are not committed, browser state, APIs and logs. It is not copied into scanner profiles.
placed in Ops, returned by APIs or written to logs. 3. The BLE-selected transport is followed by an unbound DeviceInfo exchange.
3. Before a command can be considered, live MQTT status must attest the same Its live vendor ID, serial, activation and FW 3.0.2 facts form the transient
vendor device ID and serial under the exact firmware/topology profile. device binding; a saved profile is optional metadata, not protocol authority.
Replay evidence cannot establish current command authority. 4. The ten retained pre-START requests keep their exact order and response
4. Identity drift, missing identity, malformed status, profile mismatch or an boundaries. The only mutation is the observed time/timezone sync; the other
unexpected lifecycle state blocks command planning. nine requests are reads. No request is batched, skipped or automatically
5. START retains the observed LixelGO parameters: record-and-calculate mode 2, retried.
LCC scan mode 1, handheld mount mode 0 and the operator's validated project 5. Live DeviceInfo binding must agree with the live DeviceStatus stream before
name. STOP contains only the same bound header and action. START/STOP planning. Identity drift, malformed data, inactive equipment,
6. MQTT command semantics remain QoS 2, retain false. Automatic retry is profile mismatch or an unexpected lifecycle state fails closed.
forbidden: an unknown outcome must be reconciled from live status and 6. START retains record-and-calculate mode 2, LCC mode 1, handheld mount 0 and
physical evidence before another command. the validated project name. STOP contains only bound header and action.
7. No firmware, activation, account, update or vendor-cloud endpoint is part of 7. MQTT application semantics remain QoS 2, retain false. An unknown outcome is
the control path. Local direct-LAN MQTT is the only reviewed transport. reconciled from correlated response, status and physical evidence before any
8. STOP completion has four gates: correlated command result, local stream new operator-authorized attempt.
quiescence and evidence sealing, READY/steady-green device state, then native 8. No firmware, activation, account, update or vendor-cloud mutation belongs to
project verification. Earlier gates must not claim the later ones. 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 pre-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.
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 ## Current implementation boundary
`LiveModelingControlSafety` observes only live device-status messages. It binds `application_bootstrap.py` provides the bounded application authority type,
vendor identity and serial, tracks lifecycle state and creates non-executable DeviceInfo response correlation/decoding and the exact non-executable ten-step
shadow START/STOP plans. Public state and plan output contain only booleans, shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic
counts, action, topic, QoS, retain flag, payload length and digest. The encoded order. `LiveModelingControlSafety` independently binds that DeviceInfo identity
payload and enrolled values remain private in memory. to live status and creates non-executable START/STOP plans.
The shadow plan is blocked by both `vendor-writes-disabled` and Public state contains only booleans, counts and wire metadata. Private identity,
`publisher-not-installed`. There is no MQTT publisher, enrollment endpoint or authority and payload bytes stay out of repr/API output. Both shadow paths are
automatic retry path. The physical K1 control remains the production fallback. blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT
publisher or automatic retry path exists.
## Promotion gate ## Promotion gate
A future publisher requires a separate review and an operator-present physical A future publisher requires separate review and an operator-present physical
acceptance on the enrolled K1: acceptance:
1. confirm exact firmware/profile, steady-green state, battery and storage; 1. load the private application authority through a reviewed local secret
2. close LixelGO and arm raw evidence plus the intended camera receiver; mechanism;
3. attest live READY identity against the device enrollment; 2. confirm battery/storage and select one K1 over BLE;
4. review the shadow command metadata; 3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
5. send one START, without retry, and correlate its exact response; 4. confirm READY identity against DeviceInfo and review shadow metadata;
5. send one START without retry and correlate its response;
6. observe calibration and first point/pose/camera data; 6. observe calibration and first point/pose/camera data;
7. send one STOP, without retry, and correlate its exact response; 7. send one STOP without retry and correlate its response;
8. seal local evidence while accepting the bounded stream tail; 8. seal local evidence while accepting the bounded stream tail;
9. wait for READY and steady green before power-off or another scan; 9. wait for READY and steady green;
10. verify the native project independently through the vendor-supported 10. verify the native project through the vendor-supported workflow.
workflow.
Any unknown response, identity change, activation state, fault, low battery or Any unknown response, identity change, fault, low battery/storage or unexpected
unexpected lifecycle transition pauses the test. It does not trigger a guessed transition pauses the test. It never triggers a guessed recovery command.
recovery command.
## Consequences ## Consequences
Adding another K1 means creating another private enrollment and proving its Adding another identical K1 means selecting it over BLE and deriving a fresh
exact compatibility profile; it never means reusing captured bytes from the live DeviceInfo binding, not creating or copying a command profile. A second
first scanner. The design preserves native record-and-calculate behavior while physical K1 remains a useful portability acceptance, but another packet capture
keeping command authority inside the XGRIDS plugin and out of generic Mission is not required before implementing the reviewed current profile. The design
Core. It intentionally postpones convenience automation until identity, preserves native record-and-calculate behavior while keeping vendor control in
credential provenance, one-shot transport behavior and durable save semantics the XGRIDS plugin and out of generic Mission Core.
have all passed physical acceptance.

View File

@ -425,7 +425,7 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
if mapping.get("header_contract") != { if mapping.get("header_contract") != {
"device_id": "explicit-observed-identity", "device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest", "session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance", "openapi_key": "private-application-level-runtime-authority",
}: }:
raise CompatibilityProfileError( raise CompatibilityProfileError(
f"{action_id} header contract differs from retained evidence" f"{action_id} header contract differs from retained evidence"

View File

@ -37,9 +37,10 @@ is observed, but its media decoder/replay flags remain independent.
The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
capture verifies the `ModelingRequest` topic, action values, field layout, capture verifies the `ModelingRequest` topic, action values, field layout,
literal `{device_id}:ModelingRequest` session relation, retained start settings literal `{device_id}:ModelingRequest` session relation, retained start settings
and numeric success code. The bounded codec accepts an OpenAPI value only as an and numeric success code. Retained client/wire evidence identifies OpenAPI as
explicit secret; its provenance and secure provisioning are unresolved, as are one private application-level value rather than a per-scanner profile. Its
durable save completion, timeout and rollback behavior. Acquisition therefore secure runtime loading remains unresolved, as do durable save completion,
timeout and rollback behavior. Acquisition therefore
stays `operator-manual` through the verified physical double-click. stays `operator-manual` through the verified physical double-click.
The standalone encoder models the recovered wire schema, including enum values The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future outside the retained request. It is not an authorization policy: any future

View File

@ -348,7 +348,7 @@
"header_contract": { "header_contract": {
"device_id": "explicit-observed-identity", "device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest", "session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance" "openapi_key": "private-application-level-runtime-authority"
}, },
"request_fields": { "request_fields": {
"project_name": "required-operator-value", "project_name": "required-operator-value",
@ -359,7 +359,7 @@
}, },
"success_result_code": 302252033, "success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"OpenAPI credential provenance and secure provisioning", "secure private application-authority loading",
"authorization policy for any setting outside the retained request", "authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract" "timeout, rejection and rollback contract"
], ],
@ -401,12 +401,12 @@
"header_contract": { "header_contract": {
"device_id": "explicit-observed-identity", "device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest", "session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance" "openapi_key": "private-application-level-runtime-authority"
}, },
"request_fields": {}, "request_fields": {},
"success_result_code": 302252033, "success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"OpenAPI credential provenance and secure provisioning", "secure private application-authority loading",
"save-completion and final-standby state mapping", "save-completion and final-standby state mapping",
"timeout and rollback contract" "timeout and rollback contract"
], ],

View File

@ -0,0 +1,502 @@
from __future__ import annotations
import hashlib
import hmac
import re
from dataclasses import dataclass, field
from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError,
ProtoField,
iter_fields,
)
MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
DEVICE_INFO_REQUEST_TOPIC = "lixel/application/request/device_info"
DEVICE_INFO_RESPONSE_TOPIC = "lixel/application/response/device_info"
MODELING_STATUS_REQUEST_TOPIC = "lixel/application/request/modeling_status"
MODELING_STATUS_RESPONSE_TOPIC = "lixel/application/response/modeling_status"
GET_RTK_ADVANCE_REQUEST_TOPIC = "lixel/application/request/get_rtk_advance"
GET_RTK_ADVANCE_RESPONSE_TOPIC = "lixel/application/response/get_rtk_advance"
DEVICE_CONFIG_REQUEST_TOPIC = "lixel/application/request/device_config"
DEVICE_CONFIG_RESPONSE_TOPIC = "lixel/application/response/device_config"
GET_NTRIP_PROFILE_REQUEST_TOPIC = "lixel/application/request/get_ntrip_profile"
GET_NTRIP_PROFILE_RESPONSE_TOPIC = "lixel/application/response/get_ntrip_profile"
GET_CLOUD_CONFIG_REQUEST_TOPIC = "lixel/application/request/get_cloud_server_config"
GET_CLOUD_CONFIG_RESPONSE_TOPIC = "lixel/application/response/get_cloud_server_config"
class ApplicationBootstrapError(ValueError):
"""The recovered K1 application bootstrap contract was violated."""
@dataclass(frozen=True, slots=True)
class ApplicationControlAuthority:
"""Private authority belonging to the reviewed LixelGO application profile.
The retained application and wire evidence show one OpenAPI value shared by
all request types. It is not a scanner serial or a per-device enrollment.
"""
openapi_key: str = field(repr=False)
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
)
def __post_init__(self) -> None:
if (
self.source != APPLICATION_AUTHORITY_SOURCE
or self.compatibility_profile_id != COMPATIBILITY_PROFILE_ID
):
raise ApplicationBootstrapError(
"application authority does not match the reviewed K1 profile"
)
_validate_ascii_identity(self.openapi_key, "openapi_key")
@dataclass(frozen=True, slots=True)
class LiveDeviceControlBinding:
"""Identity and compatibility facts learned from the selected live K1."""
vendor_device_id: str = field(repr=False)
device_serial: str = field(repr=False)
software_version: str = field(repr=False)
system_version: str = field(repr=False)
device_model: str = field(repr=False)
device_type: str = field(repr=False)
is_activated: bool
def __post_init__(self) -> None:
_validate_ascii_identity(self.vendor_device_id, "vendor_device_id")
_validate_ascii_identity(self.device_serial, "device_serial")
for name, value in (
("software_version", self.software_version),
("system_version", self.system_version),
("device_model", self.device_model),
("device_type", self.device_type),
):
_validate_text(value, name)
@property
def matches_reviewed_firmware(self) -> bool:
return _contains_version(self.software_version, "3.0.2") and _contains_version(
self.system_version, "3.0.2"
)
@property
def ready_for_reviewed_profile(self) -> bool:
return self.is_activated and self.matches_reviewed_firmware
@dataclass(frozen=True, slots=True)
class ApplicationRequestHeader:
message_type: str
authority: ApplicationControlAuthority = field(repr=False)
binding: LiveDeviceControlBinding | None = field(default=None, repr=False)
context: str | None = None
def __post_init__(self) -> None:
_validate_ascii_identity(self.message_type, "message_type")
if self.context is not None:
_validate_ascii_identity(self.context, "context")
if len(self.session_id.encode("ascii")) > MAX_TEXT_BYTES:
raise ApplicationBootstrapError("derived session_id exceeds configured limit")
@property
def session_id(self) -> str:
device_id = self.binding.vendor_device_id if self.binding is not None else ""
session = f"{device_id}:{self.message_type}"
if self.context is not None:
session = f"{session}:{self.context}"
return session
@dataclass(frozen=True, slots=True)
class EncodedApplicationRequest:
ordinal: int
phase: Literal["identity-discovery", "bound-preparation"]
message_type: str
topic: str
response_topic: str
payload: bytes = field(repr=False)
payload_sha256: str
payload_bytes: int
mutates_device: bool
requires_live_binding: bool
qos: int = 2
retain: bool = False
def as_dict(self) -> dict[str, object]:
return {
"ordinal": self.ordinal,
"phase": self.phase,
"message_type": self.message_type,
"topic": self.topic,
"response_topic": self.response_topic,
"payload_sha256": self.payload_sha256,
"payload_bytes": self.payload_bytes,
"mutates_device": self.mutates_device,
"requires_live_binding": self.requires_live_binding,
"qos": self.qos,
"retain": self.retain,
}
@dataclass(frozen=True, slots=True)
class ShadowApplicationBootstrap:
"""Exact observed pre-START request order, with no execution authority."""
requests: tuple[EncodedApplicationRequest, ...] = field(repr=False)
executable: bool = False
blockers: tuple[str, ...] = SHADOW_BLOCKERS
retry_policy: Literal["never-automatic"] = "never-automatic"
def as_dict(self) -> dict[str, object]:
return {
"mode": "shadow-only",
"request_count": len(self.requests),
"mutation_count": sum(request.mutates_device for request in self.requests),
"requests": [request.as_dict() for request in self.requests],
"executable": self.executable,
"blockers": list(self.blockers),
"retry_policy": self.retry_policy,
}
@dataclass(frozen=True, slots=True)
class DeviceInfoResponse:
binding: LiveDeviceControlBinding = field(repr=False)
session_id: str = field(repr=False)
openapi_key: str = field(repr=False)
result_code: int
def build_shadow_bootstrap(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
*,
epoch_seconds: int,
timezone_name: str,
) -> ShadowApplicationBootstrap:
"""Build the ten pre-START requests observed in the clean LixelGO cycle.
The first three requests intentionally omit the vendor device ID. The
remaining requests are bound to identity returned by ``DeviceInfoResponse``.
The only preparatory mutation is the observed clock/timezone sync.
"""
if not binding.ready_for_reviewed_profile:
raise ApplicationBootstrapError(
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
raise ApplicationBootstrapError("epoch_seconds must be an integer")
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
raise ApplicationBootstrapError("epoch_seconds is outside recovered int64 range")
_validate_text(timezone_name, "timezone_name")
specs = (
(
"identity-discovery",
"DeviceInfoRequest",
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
),
(
"identity-discovery",
"ModelingStatusRequest",
MODELING_STATUS_REQUEST_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
None,
False,
),
(
"identity-discovery",
"GetRtkAdvanceRequest",
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"DeviceConfigRequest",
DEVICE_CONFIG_REQUEST_TOPIC,
DEVICE_CONFIG_RESPONSE_TOPIC,
DEVICE_CONFIG_TIME_CONTEXT,
True,
),
(
"bound-preparation",
"DeviceInfoRequest",
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"GetRtkAdvanceRequest",
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"GetNtripProfileRequest",
GET_NTRIP_PROFILE_REQUEST_TOPIC,
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"GetCloudServerConfigRequest",
GET_CLOUD_CONFIG_REQUEST_TOPIC,
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"GetRtkAdvanceRequest",
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
),
(
"bound-preparation",
"DeviceInfoRequest",
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
),
)
requests: list[EncodedApplicationRequest] = []
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
specs, start=1
):
request_binding = None if ordinal <= 3 else binding
header = ApplicationRequestHeader(
message_type=message_type,
authority=authority,
binding=request_binding,
context=context,
)
body = b""
if message_type == "DeviceConfigRequest":
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
body = _bytes_field(4, time_config)
payload = _bytes_field(1, _encode_header(header)) + body
_check_payload(payload, "application request")
requests.append(
EncodedApplicationRequest(
ordinal=ordinal,
phase=phase, # type: ignore[arg-type]
message_type=message_type,
topic=topic,
response_topic=response_topic,
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
mutates_device=mutates,
requires_live_binding=ordinal > 3,
)
)
return ShadowApplicationBootstrap(tuple(requests))
def decode_and_bind_device_info_response(
payload: bytes,
authority: ApplicationControlAuthority,
) -> DeviceInfoResponse:
"""Correlate the bootstrap response and derive a live per-device binding."""
_check_payload(payload, "DeviceInfoResponse")
top = _unique_fields(payload, "DeviceInfoResponse", max_fields=64)
header = _unique_fields(_required_bytes(top, 1, "response.header"), "header", max_fields=32)
device_id = _identity_field(header, 4, "header.device_id")
session_id = _identity_field(header, 5, "header.session_id")
openapi_key = _identity_field(header, 6, "header.openapi_key")
if session_id != ":DeviceInfoRequest":
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
if not hmac.compare_digest(openapi_key, authority.openapi_key):
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
error = _unique_fields(_required_bytes(top, 15, "response.error"), "error", max_fields=8)
result_code = _required_uint(error, 1, "response.error.code")
if result_code != OPENAPI_SUCCESS:
raise ApplicationBootstrapError(
f"DeviceInfoResponse rejected with result code {result_code}"
)
device_info = _unique_fields(
_required_bytes(top, 2, "response.device_info"), "device_info", max_fields=32
)
base_info = _unique_fields(
_required_bytes(device_info, 2, "device_info.base_info"),
"base_info",
max_fields=32,
allow_repeated={11},
)
working_status = _unique_fields(
_required_bytes(device_info, 7, "device_info.working_status"),
"working_status",
max_fields=16,
)
binding = LiveDeviceControlBinding(
vendor_device_id=device_id,
device_serial=_required_text(base_info, 7, "base_info.device_sn"),
software_version=_required_text(base_info, 2, "base_info.software_version"),
system_version=_required_text(base_info, 3, "base_info.system_version"),
device_model=_required_text(base_info, 6, "base_info.device_model"),
device_type=_required_text(base_info, 8, "base_info.device_type"),
is_activated=bool(_required_bool(working_status, 1, "working_status.is_activated")),
)
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
def _encode_header(header: ApplicationRequestHeader) -> bytes:
parts: list[bytes] = []
if header.binding is not None:
parts.append(_text_field(4, header.binding.vendor_device_id))
parts.extend(
(
_text_field(5, header.session_id),
_text_field(6, header.authority.openapi_key),
)
)
payload = b"".join(parts)
if len(payload) > MAX_HEADER_BYTES:
raise ApplicationBootstrapError("encoded request header exceeds configured limit")
return payload
def _contains_version(value: str, expected: str) -> bool:
return re.search(rf"(?<!\d){re.escape(expected)}(?!\d)", value) is not None
def _validate_ascii_identity(value: object, name: str) -> None:
if not isinstance(value, str) or not value:
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
try:
encoded = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ApplicationBootstrapError(f"{name} must use printable ASCII") from exc
if len(encoded) > MAX_TEXT_BYTES:
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
raise ApplicationBootstrapError(f"{name} must use printable ASCII without spaces")
def _validate_text(value: object, name: str) -> None:
if not isinstance(value, str) or not value:
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
encoded = value.encode("utf-8")
if len(encoded) > MAX_TEXT_BYTES:
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
raise ApplicationBootstrapError(f"{name} contains a control character")
def _check_payload(payload: bytes, name: str) -> None:
if len(payload) > MAX_APPLICATION_PAYLOAD_BYTES:
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
def _unique_fields(
payload: bytes,
name: str,
*,
max_fields: int,
allow_repeated: set[int] | None = None,
) -> dict[int, ProtoField]:
result: dict[int, ProtoField] = {}
repeated = allow_repeated or set()
try:
for item in iter_fields(payload, max_fields=max_fields):
if item.number in result and item.number not in repeated:
raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated")
result[item.number] = item
except ProtobufWireError as exc:
raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc
return result
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
item = fields.get(number)
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
return item.value
def _required_text(fields: dict[int, ProtoField], number: int, name: str) -> str:
raw = _required_bytes(fields, number, name)
if len(raw) > MAX_TEXT_BYTES:
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
try:
value = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ApplicationBootstrapError(f"{name} is not valid UTF-8") from exc
_validate_text(value, name)
return value
def _identity_field(fields: dict[int, ProtoField], number: int, name: str) -> str:
value = _required_text(fields, number, name)
_validate_ascii_identity(value, name)
return value
def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
item = fields.get(number)
if item is None or item.wire_type != 0 or not isinstance(item.value, int):
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
return item.value
def _required_bool(fields: dict[int, ProtoField], number: int, name: str) -> int:
value = _required_uint(fields, number, name)
if value not in (0, 1):
raise ApplicationBootstrapError(f"{name} is not a protobuf boolean")
return value
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _varint_field(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _bytes_field(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _text_field(number: int, value: str) -> bytes:
return _bytes_field(number, value.encode("utf-8"))

View File

@ -5,12 +5,15 @@ import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
CommandHeaderIdentity, CommandHeaderIdentity,
DeviceStatusReport, DeviceStatusReport,
EncodedModelingCommand, EncodedModelingCommand,
ModelingAction, ModelingAction,
ModelingEncodeError,
MountType, MountType,
RecordMode, RecordMode,
ScanMode, ScanMode,
@ -25,59 +28,12 @@ from k1link.viewer.metrics import BridgeMetrics
DEVICE_STATUS_TOPIC = "lixel/application/report/device_status" DEVICE_STATUS_TOPIC = "lixel/application/report/device_status"
MODELING_REQUEST_TOPIC = "lixel/application/request/modeling" MODELING_REQUEST_TOPIC = "lixel/application/request/modeling"
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed") SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
CONTROL_ENROLLMENT_SOURCE = "owner-captured-device-bound"
CONTROL_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
CONTROL_FIRMWARE_VERSION = "3.0.2"
CONTROL_TOPOLOGY = "direct-lan"
class ModelingControlSafetyError(RuntimeError): class ModelingControlSafetyError(RuntimeError):
"""A live K1 could not be bound to reviewed device-specific control input.""" """A live K1 could not be bound to reviewed device-specific control input."""
@dataclass(frozen=True, slots=True)
class DeviceBoundControlEnrollment:
"""Private, device-specific values learned from owner-controlled evidence.
Mission Core's provisional device UUID, BLE transport UUID and K1 serial are
not interchangeable with the vendor header identity. An enrollment binds
all three vendor inputs explicitly and keeps them out of repr/API output.
"""
vendor_device_id: str = field(repr=False)
device_serial: str = field(repr=False)
openapi_key: str = field(repr=False)
source: Literal["owner-captured-device-bound"] = "owner-captured-device-bound"
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
)
firmware_version: Literal["3.0.2"] = "3.0.2"
topology: Literal["direct-lan"] = "direct-lan"
def __post_init__(self) -> None:
if (
self.source != CONTROL_ENROLLMENT_SOURCE
or self.compatibility_profile_id != CONTROL_COMPATIBILITY_PROFILE_ID
or self.firmware_version != CONTROL_FIRMWARE_VERSION
or self.topology != CONTROL_TOPOLOGY
):
raise ModelingControlSafetyError(
"control enrollment does not match the exact reviewed K1 profile"
)
CommandHeaderIdentity(
device_id=self.vendor_device_id,
openapi_key=self.openapi_key,
)
_validate_device_serial(self.device_serial)
@property
def command_header(self) -> CommandHeaderIdentity:
return CommandHeaderIdentity(
device_id=self.vendor_device_id,
openapi_key=self.openapi_key,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModelingControlSafetySnapshot: class ModelingControlSafetySnapshot:
status_reports_observed: int status_reports_observed: int
@ -215,13 +171,14 @@ class LiveModelingControlSafety:
def plan_start( def plan_start(
self, self,
enrollment: DeviceBoundControlEnrollment, authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
*, *,
project_name: str, project_name: str,
) -> ShadowModelingCommand: ) -> ShadowModelingCommand:
self._require_bound(enrollment, required_state=SessionState.READY, project_bound=False) self._require_bound(binding, required_state=SessionState.READY, project_bound=False)
command = encode_modeling_start( command = encode_modeling_start(
enrollment.command_header, _command_header(authority, binding),
project_name=project_name, project_name=project_name,
record_mode=RecordMode.RECORD_AND_CALCULATE, record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC, scan_mode=ScanMode.LCC,
@ -231,14 +188,17 @@ class LiveModelingControlSafety:
def plan_stop( def plan_stop(
self, self,
enrollment: DeviceBoundControlEnrollment, authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> ShadowModelingCommand: ) -> ShadowModelingCommand:
self._require_bound(enrollment, required_state=SessionState.SCANNING, project_bound=True) self._require_bound(binding, required_state=SessionState.SCANNING, project_bound=True)
return ShadowModelingCommand.from_command(encode_modeling_stop(enrollment.command_header)) return ShadowModelingCommand.from_command(
encode_modeling_stop(_command_header(authority, binding))
)
def _require_bound( def _require_bound(
self, self,
enrollment: DeviceBoundControlEnrollment, binding: LiveDeviceControlBinding,
*, *,
required_state: SessionState, required_state: SessionState,
project_bound: bool, project_bound: bool,
@ -250,11 +210,15 @@ class LiveModelingControlSafety:
if report is None or self._vendor_device_id is None or self._device_serial is None: if report is None or self._vendor_device_id is None or self._device_serial is None:
raise ModelingControlSafetyError("live K1 identity/status evidence is missing") raise ModelingControlSafetyError("live K1 identity/status evidence is missing")
if ( if (
enrollment.vendor_device_id != self._vendor_device_id binding.vendor_device_id != self._vendor_device_id
or enrollment.device_serial != self._device_serial or binding.device_serial != self._device_serial
): ):
raise ModelingControlSafetyError( raise ModelingControlSafetyError(
"device-bound control enrollment does not match the live K1" "DeviceInfo binding does not match the live K1 status stream"
)
if not binding.ready_for_reviewed_profile:
raise ModelingControlSafetyError(
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
) )
if report.session_state is not required_state: if report.session_state is not required_state:
raise ModelingControlSafetyError( raise ModelingControlSafetyError(
@ -264,12 +228,11 @@ class LiveModelingControlSafety:
raise ModelingControlSafetyError("live K1 project binding is not in the safe state") raise ModelingControlSafetyError("live K1 project binding is not in the safe state")
def _validate_device_serial(value: object) -> None: def _command_header(
if not isinstance(value, str) or not value: authority: ApplicationControlAuthority,
raise ModelingEncodeError("device_serial must be a non-empty string") binding: LiveDeviceControlBinding,
try: ) -> CommandHeaderIdentity:
encoded = value.encode("ascii") return CommandHeaderIdentity(
except UnicodeEncodeError as exc: device_id=binding.vendor_device_id,
raise ModelingEncodeError("device_serial must use printable ASCII") from exc openapi_key=authority.openapi_key,
if len(encoded) > 256 or any(byte <= 0x20 or byte > 0x7E for byte in encoded): )
raise ModelingEncodeError("device_serial must use bounded printable ASCII without spaces")

View File

@ -0,0 +1,167 @@
from __future__ import annotations
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationBootstrapError,
ApplicationControlAuthority,
LiveDeviceControlBinding,
build_shadow_bootstrap,
decode_and_bind_device_info_response,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _uint(number: int, value: int) -> bytes:
return _varint(number << 3) + _varint(value)
def _bytes(number: int, value: bytes) -> bytes:
return _varint((number << 3) | 2) + _varint(len(value)) + value
def _text(number: int, value: str) -> bytes:
return _bytes(number, value.encode())
def _authority() -> ApplicationControlAuthority:
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
def _binding(**changes: object) -> LiveDeviceControlBinding:
values = {
"vendor_device_id": VENDOR_DEVICE_ID,
"device_serial": "K1SERIAL01",
"software_version": "V3.0.2-20260101-release",
"system_version": "V3.0.2",
"device_model": "LixelKity K1",
"device_type": "K1",
"is_activated": True,
}
values.update(changes)
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
base_info = b"".join(
(
_text(1, "2026-01-01T00:00:00"),
_text(2, "V3.0.2-20260101-release"),
_text(3, "V3.0.2"),
_text(4, "V1.2.3"),
_text(5, "V1.0"),
_text(6, "LixelKity K1"),
_text(7, "K1SERIAL01"),
_text(8, "K1"),
)
)
working_status = _uint(1, 1) + _uint(2, 0)
device_info = _bytes(2, base_info) + _bytes(7, working_status)
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
plan = build_shadow_bootstrap(
_authority(),
_binding(),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
assert [request.message_type for request in plan.requests] == [
"DeviceInfoRequest",
"ModelingStatusRequest",
"GetRtkAdvanceRequest",
"DeviceConfigRequest",
"DeviceInfoRequest",
"GetRtkAdvanceRequest",
"GetNtripProfileRequest",
"GetCloudServerConfigRequest",
"GetRtkAdvanceRequest",
"DeviceInfoRequest",
]
assert [request.payload_bytes for request in plan.requests] == [
60,
64,
63,
195,
135,
138,
140,
145,
138,
135,
]
assert sum(request.mutates_device for request in plan.requests) == 1
assert plan.requests[3].mutates_device
assert plan.executable is False
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
assert plan.retry_policy == "never-automatic"
assert APPLICATION_KEY not in repr(plan)
assert APPLICATION_KEY not in str(plan.as_dict())
def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None:
plan = build_shadow_bootstrap(
_authority(),
_binding(),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
first_header = next(iter_fields(plan.requests[0].payload)).value
assert isinstance(first_header, bytes)
first_fields = {field.number: field.value for field in iter_fields(first_header)}
assert 4 not in first_fields
assert first_fields[5] == b":DeviceInfoRequest"
config_header = next(iter_fields(plan.requests[3].payload)).value
assert isinstance(config_header, bytes)
config_fields = {field.number: field.value for field in iter_fields(config_header)}
assert config_fields[4] == VENDOR_DEVICE_ID.encode()
assert (
config_fields[5]
== (f"{VENDOR_DEVICE_ID}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime").encode()
)
def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None:
response = decode_and_bind_device_info_response(_device_info_response(), _authority())
assert response.result_code == OPENAPI_SUCCESS
assert response.binding.ready_for_reviewed_profile
assert response.binding.matches_reviewed_firmware
assert response.binding.is_activated
assert VENDOR_DEVICE_ID not in repr(response)
assert APPLICATION_KEY not in repr(response)
def test_device_info_response_correlation_and_profile_attestation_fail_closed() -> None:
with pytest.raises(ApplicationBootstrapError, match="session correlation"):
decode_and_bind_device_info_response(
_device_info_response(session_id=":AnotherRequest"),
_authority(),
)
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
build_shadow_bootstrap(
_authority(),
_binding(system_version="V3.0.3"),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)

View File

@ -2,6 +2,10 @@ from __future__ import annotations
import pytest import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
MODELING_STATE_BASE, MODELING_STATE_BASE,
ModelingAction, ModelingAction,
@ -9,7 +13,6 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
) )
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
DEVICE_STATUS_TOPIC, DEVICE_STATUS_TOPIC,
DeviceBoundControlEnrollment,
LiveModelingControlSafety, LiveModelingControlSafety,
ModelingControlSafetyError, ModelingControlSafetyError,
) )
@ -61,14 +64,22 @@ def _status_message(
) )
def _enrollment(**changes: str) -> DeviceBoundControlEnrollment: def _authority() -> ApplicationControlAuthority:
return ApplicationControlAuthority(openapi_key="application-private-key")
def _binding(**changes: object) -> LiveDeviceControlBinding:
values = { values = {
"vendor_device_id": "vendor-device-001", "vendor_device_id": "vendor-device-001",
"device_serial": "serial-001", "device_serial": "serial-001",
"openapi_key": "device-bound-private-key", "software_version": "V3.0.2-build.1",
"system_version": "V3.0.2",
"device_model": "LixelKity K1",
"device_type": "K1",
"is_activated": True,
} }
values.update(changes) values.update(changes)
return DeviceBoundControlEnrollment(**values) return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
def test_live_status_identity_is_observed_without_accepting_replay() -> None: def test_live_status_identity_is_observed_without_accepting_replay() -> None:
@ -95,7 +106,7 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
safety = LiveModelingControlSafety() safety = LiveModelingControlSafety()
safety.observe(_status_message(SessionState.READY), BridgeMetrics()) safety.observe(_status_message(SessionState.READY), BridgeMetrics())
plan = safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") plan = safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
fields = {field.number: field for field in iter_fields(plan.command.payload, max_fields=16)} fields = {field.number: field for field in iter_fields(plan.command.payload, max_fields=16)}
assert plan.action is ModelingAction.START assert plan.action is ModelingAction.START
@ -109,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
assert plan.executable is False assert plan.executable is False
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed") assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
assert plan.retry_policy == "never-automatic" assert plan.retry_policy == "never-automatic"
assert "device-bound-private-key" not in repr(plan) assert "application-private-key" not in repr(plan)
assert "device-bound-private-key" not in str(plan.as_dict()) assert "application-private-key" not in str(plan.as_dict())
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None: def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
@ -120,13 +131,13 @@ def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None
BridgeMetrics(), BridgeMetrics(),
) )
plan = safety.plan_stop(_enrollment()) plan = safety.plan_stop(_authority(), _binding())
fields = list(iter_fields(plan.command.payload, max_fields=8)) fields = list(iter_fields(plan.command.payload, max_fields=8))
assert plan.action is ModelingAction.STOP assert plan.action is ModelingAction.STOP
assert [field.number for field in fields] == [1, 2] assert [field.number for field in fields] == [1, 2]
with pytest.raises(ModelingControlSafetyError, match="does not match"): with pytest.raises(ModelingControlSafetyError, match="does not match"):
safety.plan_stop(_enrollment(device_serial="another-serial")) safety.plan_stop(_authority(), _binding(device_serial="another-serial"))
def test_identity_drift_and_decode_failure_fail_closed() -> None: def test_identity_drift_and_decode_failure_fail_closed() -> None:
@ -139,7 +150,7 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None:
) )
assert safety.snapshot().identity_conflict assert safety.snapshot().identity_conflict
with pytest.raises(ModelingControlSafetyError, match="conflicted"): with pytest.raises(ModelingControlSafetyError, match="conflicted"):
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
safety.reset() safety.reset()
malformed = StreamMessage( malformed = StreamMessage(
@ -153,23 +164,31 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None:
assert safety.observe(malformed, metrics) assert safety.observe(malformed, metrics)
assert safety.snapshot().decode_errors == 1 assert safety.snapshot().decode_errors == 1
with pytest.raises(ModelingControlSafetyError, match="conflicted"): with pytest.raises(ModelingControlSafetyError, match="conflicted"):
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
def test_shadow_plan_requires_exact_safe_lifecycle_state() -> None: def test_shadow_plan_requires_exact_safe_lifecycle_state() -> None:
safety = LiveModelingControlSafety() safety = LiveModelingControlSafety()
safety.observe(_status_message(SessionState.SCAN_STARTING), BridgeMetrics()) safety.observe(_status_message(SessionState.SCAN_STARTING), BridgeMetrics())
with pytest.raises(ModelingControlSafetyError, match="must be ready"): with pytest.raises(ModelingControlSafetyError, match="must be ready"):
safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
with pytest.raises(ModelingControlSafetyError, match="must be scanning"): with pytest.raises(ModelingControlSafetyError, match="must be scanning"):
safety.plan_stop(_enrollment()) safety.plan_stop(_authority(), _binding())
def test_enrollment_rejects_runtime_profile_substitution() -> None: def test_device_info_binding_rejects_unreviewed_or_inactive_profile() -> None:
with pytest.raises(ModelingControlSafetyError, match="exact reviewed K1 profile"): safety = LiveModelingControlSafety()
DeviceBoundControlEnrollment( safety.observe(_status_message(SessionState.READY), BridgeMetrics())
vendor_device_id="vendor-device-001",
device_serial="serial-001", with pytest.raises(ModelingControlSafetyError, match="does not attest"):
openapi_key="device-bound-private-key", safety.plan_start(
firmware_version="3.0.3", # type: ignore[arg-type] _authority(),
_binding(software_version="V3.0.3"),
project_name="SAFE_PROJECT",
)
with pytest.raises(ModelingControlSafetyError, match="does not attest"):
safety.plan_start(
_authority(),
_binding(is_activated=False),
project_name="SAFE_PROJECT",
) )