feat(k1): add offline control execution gate

This commit is contained in:
DCCONSTRUCTIONS 2026-07-18 14:14:30 +03:00
parent d7749208a7
commit ea811ff370
17 changed files with 916 additions and 44 deletions

View File

@ -145,8 +145,12 @@ the response supplies live vendor identity, serial, model, activation and
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
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.
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
application authority without env/file/browser fallback, and a five-batch
orchestrator passes all retained response barriers. The installed publisher
boundary is structurally write-disabled and cannot call its sink. 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
standalone release install. The frontend consumes sibling `file:` packages from

View File

@ -21,7 +21,7 @@ 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 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 |
| 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 |
| K1 application control | PAUSE (offline execution gate complete) — exact five-batch response-barrier orchestrator passes the retained 10-request dialogue; the 36-byte application authority loads only from macOS Keychain; current publisher boundary is structurally write-disabled and never touches its sink; runtime wiring, physical START/STOP and durable save remain closed 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
@ -33,13 +33,17 @@ private application-level value in this LixelGO build, not a per-scanner
credential. The selected BLE peripheral returns its own LAN IP; the initial
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
10 observed pre-START requests byte-for-byte: three unbound discovery reads,
one response-bound time/timezone sync and six further reads. A live-only safety
layer rejects identity drift or malformed status, and can form a non-executable
START only from unbound `READY` or STOP only from bound `SCANNING`. Public output
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.
10 observed pre-START requests byte-for-byte across five recovered batches:
initial DeviceInfo binding; ModelingStatus plus RTK read; time sync plus
DeviceInfo/RTK; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
ModelingStatus request has no mandatory synchronous response barrier; readiness
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
now reads the exact 36-byte authority without environment/file/browser fallback.
The response orchestrator correlates required headers/results and rejects
identity/profile drift. The installed publish boundary is a separate
write-disabled type that cannot call its injected sink. Runtime wiring, a
reviewed write-capable type and durable post-stop save remain unproven. The
physical button remains the known-safe fallback.
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;
@ -258,7 +262,7 @@ useful stream is decoded or structurally identified.
- firmware or internal-Linux analysis;
- physical end-to-end shared-timeline playback for a newly archived session
containing point cloud plus one selected K1 camera;
- private application-level OpenAPI authority loading, authorized command transport
- operator-owned Keychain provisioning/execution lease, authorized command transport
and stable-artifact proof after the observed stop lifecycle;
- long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging.

View File

@ -188,6 +188,15 @@ 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 retained request/response timing further divides the dialogue into five
bounded batches. DeviceInfo response establishes identity before later bound
requests. Required response barriers are correlated by topic, exact session,
vendor identity, application authority and numeric success. The initial
ModelingStatus request is intentionally not treated as a required synchronous
response: the clean cycle continued without one, and readiness belongs to the
live DeviceStatus gate. The offline orchestrator completes over the retained
payloads without substitution or skipped requests.
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
shadow planner accepts START only from `READY` with no project and STOP only
@ -195,9 +204,12 @@ 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
forbidden. It has no MQTT publish dependency and cannot send a command.
Publishing remains deliberately disabled: the private application authority
still needs a secure runtime loader and the complete stop/save sequence has not
physically proved a durable vendor project. A correlated STOP success, stream quiescence, return to
Publishing remains deliberately disabled. The exact 36-byte authority now has a
read-only macOS Keychain loader with no environment, file, browser or API
fallback, but it is not wired to the live facade. The current one-shot boundary
is structurally write-disabled and cannot touch its injected sink. The complete
stop/save sequence has not 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
are separate gates. The verified physical double-click remains the acquisition
control until a separately reviewed one-shot write gate closes.

View File

@ -148,7 +148,8 @@ observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
responses, and classifies live device states without importing MQTT or
publishing anything. One private application-level OpenAPI authority is kept
separate from the transient vendor ID/serial returned by the BLE-selected K1.
Secure authority loading and durable save completion after stop remain open, so
The fixed Keychain loader and offline response orchestrator now exist, but live
execution-lease wiring and durable save completion after stop remain open, so
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
## Automatic Rerun source and lifecycle
@ -331,8 +332,8 @@ listener and its process memory must be closed unconditionally.
separate generic media path. Historical sessions predating that archive have
no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs reviewed private application-authority loading, response-gated
one-shot behavior and a proven durable-save completion gate.
publisher needs reviewed Keychain provisioning/execution-lease wiring,
response-gated one-shot behavior and a proven durable-save completion gate.
- No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting

View File

@ -148,7 +148,8 @@ START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
device-status values into an observation-only state machine. The OpenAPI value
is one private application-level authority; vendor ID and serial come from the
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
exists. Secure authority loading and stable-artifact proof after STOP remain
exists. A fixed Keychain loader and offline response orchestrator are present;
operator execution-lease wiring and stable-artifact proof after STOP remain
unresolved, so `vendor_writes_enabled=false`.
## Semantic lifecycle
@ -221,8 +222,8 @@ views.
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
caching and evolve same-host MSE delivery toward an authenticated Edge media
plane. Keep the modeling-command publisher disabled until private application
authority loading and durable-save confirmation close its separate safety
plane. Keep the modeling-command publisher disabled until operator-owned
Keychain execution-lease wiring and durable-save confirmation close its safety
gate.
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
real point-cloud plus one-camera recorded playback remains an explicit hardware
acceptance test. Automated protocol tests also do not authorize K1 modeling
publishing: private application-authority loading and durable save completion
publishing: operator-owned Keychain execution lease and durable save completion
remain separate physical/security gates.

View File

@ -114,7 +114,7 @@ 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
does not claim that it reaches the scanner: the inert application-control codec
has no publisher, and automatic K1 writes remain disabled pending reviewed
private application-authority loading and durable post-stop save evidence.
operator-owned Keychain execution-lease wiring and durable post-stop save evidence.
ADR 0011 subsequently places the action control plane behind a versioned
descriptor/handshake/health transport seam. Observation discovery and export

View File

@ -77,24 +77,30 @@ captured special relation
## Current implementation boundary
`application_bootstrap.py` provides the bounded application authority type,
DeviceInfo response correlation/decoding and the exact non-executable ten-step
shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic
order. `LiveModelingControlSafety` independently binds that DeviceInfo identity
to live status and creates non-executable START/STOP plans.
`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. Both shadow paths are
blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT
publisher or automatic retry path exists.
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.
## Promotion gate
A future publisher requires separate review and an operator-present physical
acceptance:
1. load the private application authority through a reviewed local secret
mechanism;
1. provision the reviewed private authority into the fixed local Keychain item
and load it for one operator-owned execution lease;
2. confirm battery/storage and select one K1 over BLE;
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
4. confirm READY identity against DeviceInfo and review shadow metadata;

View File

@ -38,9 +38,10 @@ The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
capture verifies the `ModelingRequest` topic, action values, field layout,
literal `{device_id}:ModelingRequest` session relation, retained start settings
and numeric success code. Retained client/wire evidence identifies OpenAPI as
one private application-level value rather than a per-scanner profile. Its
secure runtime loading remains unresolved, as do durable save completion,
timeout and rollback behavior. Acquisition therefore
one private application-level value rather than a per-scanner profile. A fixed
read-only macOS Keychain loader and offline response orchestrator now exist;
operator-owned provisioning/execution-lease wiring, durable save completion,
timeout and rollback behavior remain unresolved. Acquisition therefore
stays `operator-manual` through the verified physical double-click.
The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future

View File

@ -359,7 +359,7 @@
},
"success_result_code": 302252033,
"required_unresolved_context": [
"secure private application-authority loading",
"operator-owned Keychain authority provisioning and execution-lease wiring",
"authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract"
],
@ -406,7 +406,7 @@
"request_fields": {},
"success_result_code": 302252033,
"required_unresolved_context": [
"secure private application-authority loading",
"operator-owned Keychain authority provisioning and execution-lease wiring",
"save-completion and final-standby state mapping",
"timeout and rollback contract"
],

View File

@ -0,0 +1,118 @@
from __future__ import annotations
import platform
import shutil
import subprocess
from dataclasses import dataclass
from typing import Protocol
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationBootstrapError,
ApplicationControlAuthority,
)
KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
KEYCHAIN_TIMEOUT_SECONDS = 5.0
class ApplicationAuthorityLoadError(RuntimeError):
"""The private application authority could not be loaded safely."""
class CommandRunner(Protocol):
def __call__(
self,
args: list[str],
*,
capture_output: bool,
check: bool,
timeout: float,
) -> subprocess.CompletedProcess[bytes]: ...
@dataclass(frozen=True, slots=True)
class ApplicationAuthoritySourceSnapshot:
provider: str = "macos-keychain"
service: str = KEYCHAIN_SERVICE
account: str = KEYCHAIN_ACCOUNT
secret_cached: bool = False
secret_exportable: bool = False
file_fallback_enabled: bool = False
environment_fallback_enabled: bool = False
def as_dict(self) -> dict[str, object]:
return {
"provider": self.provider,
"service": self.service,
"account": self.account,
"secret_cached": self.secret_cached,
"secret_exportable": self.secret_exportable,
"file_fallback_enabled": self.file_fallback_enabled,
"environment_fallback_enabled": self.environment_fallback_enabled,
}
class MacOSKeychainApplicationAuthorityLoader:
"""Read the exact-profile authority from the current user's Keychain.
There is deliberately no environment, plaintext-file, browser or API
fallback. The secret is requested through the absolute ``security`` binary
and is never interpolated into an exception, repr or subprocess argument.
"""
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
self._runner = runner
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
return ApplicationAuthoritySourceSnapshot()
def load(self) -> ApplicationControlAuthority:
if platform.system() != "Darwin":
raise ApplicationAuthorityLoadError(
"application authority loading is supported only through macOS Keychain"
)
security = shutil.which("security")
if security != "/usr/bin/security":
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
try:
completed = self._runner(
[
security,
"find-generic-password",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
"-w",
],
capture_output=True,
check=False,
timeout=KEYCHAIN_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
if completed.returncode != 0:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
secret_buffer = bytearray(completed.stdout)
try:
while secret_buffer.endswith((b"\n", b"\r")):
secret_buffer.pop()
try:
secret = secret_buffer.decode("ascii")
except UnicodeDecodeError as exc:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority has an invalid encoding"
) from exc
try:
return ApplicationControlAuthority(openapi_key=secret)
except ApplicationBootstrapError as exc:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority does not match the reviewed profile"
) from exc
finally:
for index in range(len(secret_buffer)):
secret_buffer[index] = 0

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import hmac
import re
import threading
from dataclasses import dataclass, field
from typing import Literal
@ -62,6 +63,10 @@ class ApplicationControlAuthority:
"application authority does not match the reviewed K1 profile"
)
_validate_ascii_identity(self.openapi_key, "openapi_key")
if len(self.openapi_key) != 36:
raise ApplicationBootstrapError(
"openapi_key must match the exact 36-byte reviewed application profile"
)
@dataclass(frozen=True, slots=True)
@ -133,6 +138,9 @@ class EncodedApplicationRequest:
payload_bytes: int
mutates_device: bool
requires_live_binding: bool
response_required: bool
session_id: str = field(repr=False)
vendor_device_id: str | None = field(repr=False)
qos: int = 2
retain: bool = False
@ -147,6 +155,7 @@ class EncodedApplicationRequest:
"payload_bytes": self.payload_bytes,
"mutates_device": self.mutates_device,
"requires_live_binding": self.requires_live_binding,
"response_required": self.response_required,
"qos": self.qos,
"retain": self.retain,
}
@ -181,6 +190,39 @@ class DeviceInfoResponse:
result_code: int
@dataclass(frozen=True, slots=True)
class ApplicationResponse:
session_id: str = field(repr=False)
vendor_device_id: str = field(repr=False)
openapi_key: str = field(repr=False)
result_code: int
@dataclass(frozen=True, slots=True)
class ApplicationBootstrapSnapshot:
current_batch: int
total_batches: int
batch_issued: bool
pending_response_topics: tuple[str, ...]
live_binding_observed: bool
bootstrap_complete: bool
executable: bool = False
vendor_writes_enabled: bool = False
def as_dict(self) -> dict[str, object]:
return {
"mode": "shadow-only",
"current_batch": self.current_batch,
"total_batches": self.total_batches,
"batch_issued": self.batch_issued,
"pending_response_topics": list(self.pending_response_topics),
"live_binding_observed": self.live_binding_observed,
"bootstrap_complete": self.bootstrap_complete,
"executable": self.executable,
"vendor_writes_enabled": self.vendor_writes_enabled,
}
def build_shadow_bootstrap(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
@ -213,6 +255,7 @@ def build_shadow_bootstrap(
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
True,
),
(
"identity-discovery",
@ -221,6 +264,7 @@ def build_shadow_bootstrap(
MODELING_STATUS_RESPONSE_TOPIC,
None,
False,
False,
),
(
"identity-discovery",
@ -229,6 +273,7 @@ def build_shadow_bootstrap(
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -237,6 +282,7 @@ def build_shadow_bootstrap(
DEVICE_CONFIG_RESPONSE_TOPIC,
DEVICE_CONFIG_TIME_CONTEXT,
True,
True,
),
(
"bound-preparation",
@ -245,6 +291,7 @@ def build_shadow_bootstrap(
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -253,6 +300,7 @@ def build_shadow_bootstrap(
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -261,6 +309,7 @@ def build_shadow_bootstrap(
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -269,6 +318,7 @@ def build_shadow_bootstrap(
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -277,6 +327,7 @@ def build_shadow_bootstrap(
GET_RTK_ADVANCE_RESPONSE_TOPIC,
None,
False,
True,
),
(
"bound-preparation",
@ -285,12 +336,19 @@ def build_shadow_bootstrap(
DEVICE_INFO_RESPONSE_TOPIC,
None,
False,
True,
),
)
requests: list[EncodedApplicationRequest] = []
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
specs, start=1
):
for ordinal, (
phase,
message_type,
topic,
response_topic,
context,
mutates,
response_required,
) in enumerate(specs, start=1):
request_binding = None if ordinal <= 3 else binding
header = ApplicationRequestHeader(
message_type=message_type,
@ -316,6 +374,11 @@ def build_shadow_bootstrap(
payload_bytes=len(payload),
mutates_device=mutates,
requires_live_binding=ordinal > 3,
response_required=response_required,
session_id=header.session_id,
vendor_device_id=(
request_binding.vendor_device_id if request_binding is not None else None
),
)
)
return ShadowApplicationBootstrap(tuple(requests))
@ -324,6 +387,9 @@ def build_shadow_bootstrap(
def decode_and_bind_device_info_response(
payload: bytes,
authority: ApplicationControlAuthority,
*,
expected_session_id: str = ":DeviceInfoRequest",
expected_vendor_device_id: str | None = None,
) -> DeviceInfoResponse:
"""Correlate the bootstrap response and derive a live per-device binding."""
@ -333,8 +399,12 @@ def decode_and_bind_device_info_response(
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":
if not hmac.compare_digest(session_id, expected_session_id):
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
if expected_vendor_device_id is not None and not hmac.compare_digest(
device_id, expected_vendor_device_id
):
raise ApplicationBootstrapError("DeviceInfoResponse vendor identity mismatch")
if not hmac.compare_digest(openapi_key, authority.openapi_key):
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
@ -371,6 +441,221 @@ def decode_and_bind_device_info_response(
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
def correlate_application_response(
payload: bytes,
expected: EncodedApplicationRequest,
authority: ApplicationControlAuthority,
*,
live_binding: LiveDeviceControlBinding | None = None,
) -> ApplicationResponse:
"""Correlate a non-DeviceInfo response to an exact emitted request."""
expected_device_id = expected.vendor_device_id
if expected_device_id is None and live_binding is not None:
expected_device_id = live_binding.vendor_device_id
if expected_device_id is None:
raise ApplicationBootstrapError(
"generic response correlation requires live DeviceInfo identity"
)
_check_payload(payload, "application response")
top = _selected_unique_fields(
payload,
"application response",
max_fields=64,
selected={1, 15},
)
header = _selected_unique_fields(
_required_bytes(top, 1, "response.header"),
"header",
max_fields=32,
selected={4, 5, 6},
)
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 not hmac.compare_digest(device_id, expected_device_id):
raise ApplicationBootstrapError("application response vendor identity mismatch")
if not hmac.compare_digest(session_id, expected.session_id):
raise ApplicationBootstrapError("application response session correlation failed")
if not hmac.compare_digest(openapi_key, authority.openapi_key):
raise ApplicationBootstrapError("application response authority mismatch")
error = _selected_unique_fields(
_required_bytes(top, 15, "response.error"),
"error",
max_fields=8,
selected={1, 2},
)
result_code = _required_uint(error, 1, "response.error.code")
if result_code != OPENAPI_SUCCESS:
raise ApplicationBootstrapError(
f"application response rejected with result code {result_code}"
)
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
class ShadowApplicationBootstrapOrchestrator:
"""Advance the retained dialogue only across observed response barriers.
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
second batch had no required synchronous response before preparation
continued; readiness remains a separate live DeviceStatus gate.
"""
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
def __init__(
self,
authority: ApplicationControlAuthority,
*,
epoch_seconds: int,
timezone_name: str,
) -> None:
self._lock = threading.Lock()
self._authority = authority
self._epoch_seconds = epoch_seconds
self._timezone_name = timezone_name
self._binding: LiveDeviceControlBinding | None = None
self._plan: ShadowApplicationBootstrap | None = None
self._batch_index = 0
self._batch_issued = False
self._pending: dict[str, EncodedApplicationRequest] = {}
self._complete = False
self._initial_request = _build_initial_device_info_request(authority)
def snapshot(self) -> ApplicationBootstrapSnapshot:
with self._lock:
return ApplicationBootstrapSnapshot(
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
total_batches=len(self._BATCH_ORDINALS),
batch_issued=self._batch_issued,
pending_response_topics=tuple(sorted(self._pending)),
live_binding_observed=self._binding is not None,
bootstrap_complete=self._complete,
)
@property
def binding(self) -> LiveDeviceControlBinding | None:
with self._lock:
return self._binding
def next_batch(self) -> tuple[EncodedApplicationRequest, ...]:
"""Return the next batch once; repeated access fails closed."""
with self._lock:
if self._complete:
raise ApplicationBootstrapError("application bootstrap is already complete")
if self._batch_issued:
raise ApplicationBootstrapError(
"current application bootstrap batch was already issued"
)
requests: tuple[EncodedApplicationRequest, ...]
if self._batch_index == 0:
requests = (self._initial_request,)
else:
if self._plan is None:
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
requests = tuple(
self._plan.requests[ordinal - 1]
for ordinal in self._BATCH_ORDINALS[self._batch_index]
)
pending = {
request.response_topic: request for request in requests if request.response_required
}
if len(pending) != sum(request.response_required for request in requests):
raise ApplicationBootstrapError(
"bootstrap batch contains ambiguous required response topics"
)
if not pending:
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
self._pending = pending
self._batch_issued = True
return requests
def accept_response(self, topic: str, payload: bytes) -> None:
"""Correlate one required response and unlock only the next batch."""
with self._lock:
if not self._batch_issued:
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
expected = self._pending.get(topic)
if expected is None:
raise ApplicationBootstrapError(
"application response is not required by the current batch"
)
if expected.ordinal == 1:
response = decode_and_bind_device_info_response(payload, self._authority)
binding = response.binding
plan = build_shadow_bootstrap(
self._authority,
binding,
epoch_seconds=self._epoch_seconds,
timezone_name=self._timezone_name,
)
if plan.requests[0].payload != self._initial_request.payload:
raise ApplicationBootstrapError(
"bound bootstrap plan changed the emitted identity request"
)
self._binding = binding
self._plan = plan
else:
if self._binding is None:
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
if expected.message_type == "DeviceInfoRequest":
response = decode_and_bind_device_info_response(
payload,
self._authority,
expected_session_id=expected.session_id,
expected_vendor_device_id=self._binding.vendor_device_id,
)
if response.binding != self._binding:
raise ApplicationBootstrapError(
"live DeviceInfo facts changed during bootstrap"
)
else:
correlate_application_response(
payload,
expected,
self._authority,
live_binding=self._binding,
)
del self._pending[topic]
if self._pending:
return
self._batch_issued = False
self._batch_index += 1
if self._batch_index == len(self._BATCH_ORDINALS):
self._complete = True
def _build_initial_device_info_request(
authority: ApplicationControlAuthority,
) -> EncodedApplicationRequest:
header = ApplicationRequestHeader(
message_type="DeviceInfoRequest",
authority=authority,
)
payload = _bytes_field(1, _encode_header(header))
_check_payload(payload, "application request")
return EncodedApplicationRequest(
ordinal=1,
phase="identity-discovery",
message_type="DeviceInfoRequest",
topic=DEVICE_INFO_REQUEST_TOPIC,
response_topic=DEVICE_INFO_RESPONSE_TOPIC,
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
mutates_device=False,
requires_live_binding=False,
response_required=True,
session_id=header.session_id,
vendor_device_id=None,
)
def _encode_header(header: ApplicationRequestHeader) -> bytes:
parts: list[bytes] = []
if header.binding is not None:
@ -438,6 +723,26 @@ def _unique_fields(
return result
def _selected_unique_fields(
payload: bytes,
name: str,
*,
max_fields: int,
selected: set[int],
) -> dict[int, ProtoField]:
result: dict[int, ProtoField] = {}
try:
for item in iter_fields(payload, max_fields=max_fields):
if item.number not in selected:
continue
if item.number in result:
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):

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import hashlib
import threading
from dataclasses import dataclass, field
from typing import Protocol
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
EncodedApplicationRequest,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
ShadowModelingCommand,
)
class VendorWritesDisabledError(RuntimeError):
"""The active compatibility profile cannot touch a publish transport."""
class ApplicationPublishSink(Protocol):
def publish(
self,
*,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> None: ...
@dataclass(frozen=True, slots=True)
class OneShotPublishEnvelope:
topic: str
payload: bytes = field(repr=False)
payload_sha256: str
payload_bytes: int
qos: int
retain: bool
automatic_retry: bool = False
@classmethod
def from_bootstrap_request(
cls,
request: EncodedApplicationRequest,
) -> OneShotPublishEnvelope:
return cls(
topic=request.topic,
payload=request.payload,
payload_sha256=request.payload_sha256,
payload_bytes=request.payload_bytes,
qos=request.qos,
retain=request.retain,
)
@classmethod
def from_modeling_command(
cls,
command: ShadowModelingCommand,
) -> OneShotPublishEnvelope:
return cls(
topic=command.topic,
payload=command.command.payload,
payload_sha256=command.payload_sha256,
payload_bytes=command.payload_bytes,
qos=command.qos,
retain=command.retain,
)
def __post_init__(self) -> None:
if self.qos != 2 or self.retain or self.automatic_retry:
raise ValueError("publish envelope differs from the reviewed one-shot contract")
if self.payload_bytes != len(self.payload):
raise ValueError("publish envelope payload length mismatch")
if not hashlib.sha256(self.payload).hexdigest() == self.payload_sha256:
raise ValueError("publish envelope payload digest mismatch")
def as_dict(self) -> dict[str, object]:
return {
"topic": self.topic,
"payload_sha256": self.payload_sha256,
"payload_bytes": self.payload_bytes,
"qos": self.qos,
"retain": self.retain,
"automatic_retry": self.automatic_retry,
}
@dataclass(frozen=True, slots=True)
class WriteDisabledPublisherSnapshot:
denied_attempts: int
transport_calls: int = 0
vendor_writes_enabled: bool = False
publisher_armed: bool = False
automatic_retry: bool = False
def as_dict(self) -> dict[str, object]:
return {
"mode": "write-disabled",
"denied_attempts": self.denied_attempts,
"transport_calls": self.transport_calls,
"vendor_writes_enabled": self.vendor_writes_enabled,
"publisher_armed": self.publisher_armed,
"automatic_retry": self.automatic_retry,
}
class WriteDisabledOneShotPublisher:
"""Current-profile publish boundary that cannot call its injected sink.
A future write-capable implementation must be a separate reviewed type. It
cannot be enabled by flipping a runtime boolean on this class.
"""
def __init__(self, sink: ApplicationPublishSink) -> None:
self._sink = sink
self._lock = threading.Lock()
self._denied_attempts = 0
def publish_once(self, _envelope: OneShotPublishEnvelope) -> None:
with self._lock:
self._denied_attempts += 1
raise VendorWritesDisabledError(
"active K1 compatibility profile cannot publish vendor requests"
)
def snapshot(self) -> WriteDisabledPublisherSnapshot:
with self._lock:
return WriteDisabledPublisherSnapshot(denied_attempts=self._denied_attempts)

View File

@ -0,0 +1,110 @@
from __future__ import annotations
import subprocess
from unittest.mock import patch
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
KEYCHAIN_ACCOUNT,
KEYCHAIN_SERVICE,
ApplicationAuthorityLoadError,
MacOSKeychainApplicationAuthorityLoader,
)
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
@patch("platform.system", return_value="Darwin")
@patch("shutil.which", return_value="/usr/bin/security")
def test_authority_loads_only_from_fixed_macos_keychain_item(
_which: object,
_system: object,
) -> None:
calls: list[list[str]] = []
def runner(
args: list[str],
*,
capture_output: bool,
check: bool,
timeout: float,
) -> subprocess.CompletedProcess[bytes]:
calls.append(args)
assert capture_output
assert check is False
assert timeout == 5.0
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
loader = MacOSKeychainApplicationAuthorityLoader(runner=runner)
authority = loader.load()
assert calls == [
[
"/usr/bin/security",
"find-generic-password",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
"-w",
]
]
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
assert loader.snapshot().as_dict() == {
"provider": "macos-keychain",
"service": KEYCHAIN_SERVICE,
"account": KEYCHAIN_ACCOUNT,
"secret_cached": False,
"secret_exportable": False,
"file_fallback_enabled": False,
"environment_fallback_enabled": False,
}
@patch("platform.system", return_value="Linux")
def test_authority_loader_has_no_non_keychain_fallback(_system: object) -> None:
loader = MacOSKeychainApplicationAuthorityLoader()
with pytest.raises(ApplicationAuthorityLoadError, match="only through macOS Keychain"):
loader.load()
@patch("platform.system", return_value="Darwin")
@patch("shutil.which", return_value="/usr/bin/security")
def test_authority_loader_redacts_keychain_failures(
_which: object,
_system: object,
) -> None:
private_error = b"private-keychain-diagnostic"
def runner(
args: list[str],
**_kwargs: object,
) -> subprocess.CompletedProcess[bytes]:
return subprocess.CompletedProcess(args, 44, stdout=b"", stderr=private_error)
with pytest.raises(ApplicationAuthorityLoadError) as error:
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
assert private_error.decode() not in str(error.value)
@patch("platform.system", return_value="Darwin")
@patch("shutil.which", return_value="/usr/bin/security")
def test_authority_loader_rejects_wrong_profile_length_without_reflection(
_which: object,
_system: object,
) -> None:
invalid_secret = b"too-short-private-value"
def runner(
args: list[str],
**_kwargs: object,
) -> subprocess.CompletedProcess[bytes]:
return subprocess.CompletedProcess(args, 0, stdout=invalid_secret, stderr=b"")
with pytest.raises(ApplicationAuthorityLoadError) as error:
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
assert invalid_secret.decode() not in str(error.value)

View File

@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationBootstrapError,
ApplicationControlAuthority,
LiveDeviceControlBinding,
ShadowApplicationBootstrapOrchestrator,
build_shadow_bootstrap,
decode_and_bind_device_info_response,
)
@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
def _generic_response(session_id: str) -> bytes:
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b")
return _bytes(1, header) + repeated_vendor_body + _bytes(15, error)
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
plan = build_shadow_bootstrap(
_authority(),
@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
orchestrator = ShadowApplicationBootstrapOrchestrator(
_authority(),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
identity = orchestrator.next_batch()
assert [request.ordinal for request in identity] == [1]
with pytest.raises(ApplicationBootstrapError, match="already issued"):
orchestrator.next_batch()
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
initial_reads = orchestrator.next_batch()
assert [request.ordinal for request in initial_reads] == [2, 3]
assert [request.response_required for request in initial_reads] == [False, True]
orchestrator.accept_response(
initial_reads[1].response_topic,
_generic_response(initial_reads[1].session_id),
)
preparation = orchestrator.next_batch()
assert [request.ordinal for request in preparation] == [4, 5, 6]
device_info = preparation[1]
orchestrator.accept_response(
device_info.response_topic,
_device_info_response(session_id=device_info.session_id),
)
with pytest.raises(ApplicationBootstrapError, match="already issued"):
orchestrator.next_batch()
for request in (preparation[2], preparation[0]):
orchestrator.accept_response(
request.response_topic,
_generic_response(request.session_id),
)
ntrip = orchestrator.next_batch()
assert [request.ordinal for request in ntrip] == [7]
orchestrator.accept_response(
ntrip[0].response_topic,
_generic_response(ntrip[0].session_id),
)
final_reads = orchestrator.next_batch()
assert [request.ordinal for request in final_reads] == [8, 9, 10]
for request in final_reads:
payload = (
_device_info_response(session_id=request.session_id)
if request.message_type == "DeviceInfoRequest"
else _generic_response(request.session_id)
)
orchestrator.accept_response(request.response_topic, payload)
snapshot = orchestrator.snapshot()
assert snapshot.bootstrap_complete
assert snapshot.live_binding_observed
assert snapshot.pending_response_topics == ()
assert snapshot.executable is False
assert snapshot.vendor_writes_enabled is False
with pytest.raises(ApplicationBootstrapError, match="already complete"):
orchestrator.next_batch()
def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
orchestrator = ShadowApplicationBootstrapOrchestrator(
_authority(),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
orchestrator.next_batch()
with pytest.raises(ApplicationBootstrapError, match="not required"):
orchestrator.accept_response(
"lixel/application/response/get_rtk_advance",
_generic_response(":GetRtkAdvanceRequest"),
)
assert orchestrator.snapshot().current_batch == 1
assert orchestrator.snapshot().batch_issued
def test_application_authority_requires_exact_captured_profile_length() -> None:
with pytest.raises(ApplicationBootstrapError, match="36-byte"):
ApplicationControlAuthority(openapi_key="too-short")

View File

@ -0,0 +1,88 @@
from __future__ import annotations
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
build_shadow_bootstrap,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
VendorWritesDisabledError,
WriteDisabledOneShotPublisher,
)
PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555"
class SpySink:
def __init__(self) -> None:
self.calls = 0
def publish(
self,
*,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> None:
del topic, payload, qos, retain
self.calls += 1
def _envelope() -> OneShotPublishEnvelope:
authority = ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY)
binding = LiveDeviceControlBinding(
vendor_device_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
device_serial="K1SERIAL01",
software_version="V3.0.2-build.1",
system_version="V3.0.2",
device_model="LixelKity K1",
device_type="K1",
is_activated=True,
)
request = build_shadow_bootstrap(
authority,
binding,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
).requests[0]
return OneShotPublishEnvelope.from_bootstrap_request(request)
def test_write_disabled_publisher_never_touches_injected_transport() -> None:
sink = SpySink()
publisher = WriteDisabledOneShotPublisher(sink)
envelope = _envelope()
for _ in range(2):
with pytest.raises(VendorWritesDisabledError, match="cannot publish"):
publisher.publish_once(envelope)
assert sink.calls == 0
assert publisher.snapshot().as_dict() == {
"mode": "write-disabled",
"denied_attempts": 2,
"transport_calls": 0,
"vendor_writes_enabled": False,
"publisher_armed": False,
"automatic_retry": False,
}
def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
envelope = _envelope()
assert PRIVATE_AUTHORITY not in repr(envelope)
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
with pytest.raises(ValueError, match="one-shot contract"):
OneShotPublishEnvelope(
topic=envelope.topic,
payload=envelope.payload,
payload_sha256=envelope.payload_sha256,
payload_bytes=envelope.payload_bytes,
qos=1,
retain=False,
)

View File

@ -65,7 +65,7 @@ def _status_message(
def _authority() -> ApplicationControlAuthority:
return ApplicationControlAuthority(openapi_key="application-private-key")
return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555")
def _binding(**changes: object) -> LiveDeviceControlBinding:
@ -120,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
assert plan.executable is False
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
assert plan.retry_policy == "never-automatic"
assert "application-private-key" not in repr(plan)
assert "application-private-key" not in str(plan.as_dict())
assert "11111111-2222-3333-4444-555555555555" not in repr(plan)
assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict())
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None: