From a29b38a0d73d1ee524fa9e71582f86c204e3b708 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 18 Jul 2026 16:11:29 +0300 Subject: [PATCH] fix(k1): record redacted acceptance failures --- README.md | 7 +- docs/01_IMPLEMENTATION_PLAN.md | 18 +++- docs/05_K1_MQTT_STREAM_PROFILE.md | 18 +++- plugins/xgrids-k1/README.md | 8 +- .../protocol/application_acceptance.py | 91 +++++++++++++++++-- tests/test_xgrids_application_acceptance.py | 63 +++++++++++++ 6 files changed, 187 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0585fc9..588a068 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,11 @@ response subscriptions, QoS2 completion and the five response-gated batches. It consumes operation keys before publish and poisons unknown outcomes without retry. An interactive Keychain administration command provisions through Apple's hidden prompt without putting the authority in argv. Neither component -is wired to facade/UI; `vendor_writes_enabled` remains false and no application -command has been sent. +is wired to facade/UI; `vendor_writes_enabled` remains false. The first +operator-present physical attempt on 2026-07-18 emitted only the first six +bootstrap requests, then failed closed while correlating the third response +batch. The K1 remained `READY`; the `ModelingRequest` START was never emitted. +The temporary laboratory Keychain item was deleted after the attempt. This locked bootstrap is repeatable in the current workspace, not yet a standalone release install. The frontend consumes sibling `file:` packages from diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index 60c1db5..b6a9b24 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -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 (physical-acceptance software gate complete) — plugin v0.4.0 runtime remains dormant/write-disabled; a separate uninstalled transport now enforces the retained MQTT session, exact response subscriptions, QoS2 one-shot operation keys, five response-gated batches and poison-on-unknown behavior; Keychain provisioning and physical START/STOP remain unexecuted | +| K1 application control | PAUSE (first physical bootstrap failed closed before START) — plugin v0.4.0 runtime remains dormant/write-disabled; the uninstalled acceptance transport completed one connection, six QoS2 bootstrap publishes and five correlated responses, then rejected one live batch-3 response; K1 remained READY, START was not emitted, retry is forbidden and the temporary Keychain item was deleted | | 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 @@ -53,8 +53,15 @@ Each operation key is consumed before publish, automatic reconnect and application retry are forbidden, and any unknown outcome poisons the transport. The one-time `k1link authority provision` command delegates secret entry to the macOS Keychain TTY prompt and validates through the production loader without -receiving the value in argv/environment/file/browser state. The command has not -been run. Physical START/STOP remain untested; the button is the safe fallback. +receiving the value in argv/environment/file/browser state. During the first +physical attempt that CLI loader did not receive macOS approval, so a temporary +process-local Security.framework lab adapter supplied the already reviewed +value. The transport emitted bootstrap ordinals 1–6 and received all five +required responses through batch 3, but the orchestrator raised +`ApplicationBootstrapError` while correlating that batch. No START was emitted; +live DeviceStatus stayed READY and the local point/pose counts remained zero. +The local capture was sealed and the temporary Keychain item was deleted. +Physical START/STOP therefore remain untested; the button is the 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; @@ -273,7 +280,8 @@ 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; -- operator-owned Keychain item provisioning, operator-present physical use of - the reviewed acceptance transport and stable-artifact proof after STOP; +- OS-independent plugin/edge authority provider, redacted live-response + diagnostics for bootstrap batch 3, a newly permitted operator-present full + START/STOP attempt and stable-artifact proof after STOP; - long-running large-session WebViewer/WASM memory telemetry; - production retention, replication, encryption and cross-platform packaging. diff --git a/docs/05_K1_MQTT_STREAM_PROFILE.md b/docs/05_K1_MQTT_STREAM_PROFILE.md index 5e1a19b..f54cf97 100644 --- a/docs/05_K1_MQTT_STREAM_PROFILE.md +++ b/docs/05_K1_MQTT_STREAM_PROFILE.md @@ -225,13 +225,25 @@ post-publish outcome. A 15–120 second permit requires explicit operator-presen owner-controlled-device, LixelGO-closed, battery/storage and expected-state confirmations. START additionally requires the complete five-batch bootstrap; STOP requires its own separate permit. This code is not installed as a plugin -action and has never emitted to the K1. +action. On 2026-07-18 its first operator-present physical attempt made one +connection and emitted bootstrap ordinals 1–6. All six QoS 2 publishes completed +and five required responses were collected, but exact correlation rejected one +response in batch 3. The executor stopped before building or publishing START; +K1 remained `READY`, project binding stayed absent and point/pose remained zero. +No automatic retry was attempted. The one-time Keychain administration command uses `security -w` as the final argument so Apple's own TTY prompt receives the authority. Mission Core never accepts it through CLI argv and validates the resulting fixed item through the -production loader. The command has not yet been run. The complete -stop/save sequence has not physically proved a durable vendor project. A +production loader. The macOS CLI approval path timed out during the first lab +attempt; a temporary Security.framework-backed item was used only inside the +one-shot process and deleted immediately afterward. It is not the product +authority architecture. The acceptance executor now records only ordinal, +message type, response topic, payload size, SHA-256 and a safe correlation +reason; it never exposes response bytes or authority in diagnostics. The +retained clean-cycle still completes all nine bootstrap barriers offline, so +the open issue is a live batch-3 response divergence. 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 diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md index 3818053..347e400 100644 --- a/plugins/xgrids-k1/README.md +++ b/plugins/xgrids-k1/README.md @@ -87,8 +87,12 @@ coordinator has no request-emission method, no live MQTT sink and no UI control; transport now implements exact subscriptions, QoS2 completion, response barriers and no-retry poisoning. The admin CLI can provision the fixed Keychain item through Apple's hidden prompt without accepting the value as an argument. -Neither path is imported by the facade; Keychain provisioning and a controlled -physical START/STOP acceptance are still required. +Neither path is imported by the facade. The first physical attempt emitted only +bootstrap ordinals 1–6 and failed closed on live batch-3 response correlation; +START was not emitted and the K1 remained READY. The temporary lab Keychain item +was deleted. An OS-independent plugin/edge authority provider, redacted live +response comparison and a newly permitted full START/STOP acceptance remain +required. The optional owner-controlled iPhone/LixelGO observation tool lives under [`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py index 0958ae4..c874a47 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import math import threading import time @@ -8,6 +9,7 @@ from dataclasses import dataclass from typing import Literal, Protocol from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationBootstrapError, LiveDeviceControlBinding, ShadowApplicationBootstrapOrchestrator, ) @@ -19,6 +21,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( ) from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( ModelingAction, + ModelingProtocolError, ModelingResponse, correlate_modeling_response, ) @@ -130,6 +133,8 @@ class PhysicalAcceptanceDialogueExecutor: self._permit = permit self._bootstrap_complete = False self._command_complete = False + self._response_evidence: list[dict[str, object]] = [] + self._correlation_failure: dict[str, object] | None = None def run_bootstrap( self, @@ -151,10 +156,30 @@ class PhysicalAcceptanceDialogueExecutor: ) for request in batch: if request.response_required: - orchestrator.accept_response( - request.response_topic, - responses[request.response_topic], + payload = responses[request.response_topic] + self._record_response_evidence( + phase="bootstrap", + operation_key=( + f"bootstrap:{request.ordinal}:{request.message_type}" + ), + response_topic=request.response_topic, + payload=payload, ) + try: + orchestrator.accept_response(request.response_topic, payload) + except ApplicationBootstrapError as exc: + self._record_correlation_failure( + phase="bootstrap", + operation_key=( + f"bootstrap:{request.ordinal}:{request.message_type}" + ), + response_topic=request.response_topic, + reason=str(exc), + ) + raise ApplicationAcceptanceError( + "bootstrap response correlation failed for " + f"ordinal {request.ordinal} ({request.message_type}): {exc}" + ) from exc binding = orchestrator.binding if binding is None: @@ -173,10 +198,25 @@ class PhysicalAcceptanceDialogueExecutor: [OneShotPublishEnvelope.from_modeling_command(command)], required_response_topics={MODELING_RESPONSE_TOPIC}, ) - return correlate_modeling_response( - responses[MODELING_RESPONSE_TOPIC], - command.command, + payload = responses[MODELING_RESPONSE_TOPIC] + self._record_response_evidence( + phase="modeling", + operation_key=f"modeling:{command.action.name.casefold()}", + response_topic=MODELING_RESPONSE_TOPIC, + payload=payload, ) + try: + return correlate_modeling_response(payload, command.command) + except ModelingProtocolError as exc: + self._record_correlation_failure( + phase="modeling", + operation_key=f"modeling:{command.action.name.casefold()}", + response_topic=MODELING_RESPONSE_TOPIC, + reason=str(exc), + ) + raise ApplicationAcceptanceError( + f"modeling response correlation failed for {command.action.name}: {exc}" + ) from exc def snapshot(self) -> dict[str, object]: return { @@ -184,5 +224,44 @@ class PhysicalAcceptanceDialogueExecutor: "bootstrap_complete": self._bootstrap_complete, "command_complete": self._command_complete, "permit": self._permit.snapshot(), + "response_evidence": tuple(dict(item) for item in self._response_evidence), + "correlation_failure": ( + dict(self._correlation_failure) + if self._correlation_failure is not None + else None + ), "automatic_retry": False, } + + def _record_response_evidence( + self, + *, + phase: Literal["bootstrap", "modeling"], + operation_key: str, + response_topic: str, + payload: bytes, + ) -> None: + self._response_evidence.append( + { + "phase": phase, + "operation_key": operation_key, + "response_topic": response_topic, + "payload_bytes": len(payload), + "payload_sha256": hashlib.sha256(payload).hexdigest(), + } + ) + + def _record_correlation_failure( + self, + *, + phase: Literal["bootstrap", "modeling"], + operation_key: str, + response_topic: str, + reason: str, + ) -> None: + self._correlation_failure = { + "phase": phase, + "operation_key": operation_key, + "response_topic": response_topic, + "reason": reason, + } diff --git a/tests/test_xgrids_application_acceptance.py b/tests/test_xgrids_application_acceptance.py index 4e3b2f6..96ee05b 100644 --- a/tests/test_xgrids_application_acceptance.py +++ b/tests/test_xgrids_application_acceptance.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib from collections.abc import Collection, Sequence import pytest @@ -11,6 +12,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import ( PhysicalAcceptancePermit, ) from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + DEVICE_CONFIG_RESPONSE_TOPIC, ApplicationControlAuthority, ShadowApplicationBootstrapOrchestrator, ) @@ -186,10 +188,71 @@ def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit( assert permit.snapshot()["consumed"] is True assert executor.snapshot()["bootstrap_complete"] is True assert executor.snapshot()["command_complete"] is True + response_evidence = executor.snapshot()["response_evidence"] + assert isinstance(response_evidence, tuple) + assert len(response_evidence) == 10 + assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest" + assert response_evidence[-1]["operation_key"] == "modeling:start" + assert all("payload" not in item for item in response_evidence) + assert APPLICATION_KEY not in str(executor.snapshot()) + assert VENDOR_DEVICE_ID not in str(executor.snapshot()) with pytest.raises(ApplicationAcceptanceError, match="already attempted"): executor.execute_modeling(command) +def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None: + class CorruptDeviceConfigTransport(SyntheticAcceptanceTransport): + def exchange_batch_once( + self, + envelopes: Sequence[OneShotPublishEnvelope], + *, + required_response_topics: Collection[str], + ) -> dict[str, bytes]: + responses = super().exchange_batch_once( + envelopes, + required_response_topics=required_response_topics, + ) + if DEVICE_CONFIG_RESPONSE_TOPIC in responses: + responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response( + f"{VENDOR_DEVICE_ID}:wrong-session" + ) + return responses + + executor = PhysicalAcceptanceDialogueExecutor( + CorruptDeviceConfigTransport(), + PhysicalAcceptancePermit(_checklist(ModelingAction.START)), + ) + orchestrator = ShadowApplicationBootstrapOrchestrator( + ApplicationControlAuthority(openapi_key=APPLICATION_KEY), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + with pytest.raises( + ApplicationAcceptanceError, + match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation", + ): + executor.run_bootstrap(orchestrator) + + snapshot = executor.snapshot() + failure = snapshot["correlation_failure"] + assert failure == { + "phase": "bootstrap", + "operation_key": "bootstrap:4:DeviceConfigRequest", + "response_topic": DEVICE_CONFIG_RESPONSE_TOPIC, + "reason": "application response session correlation failed", + } + evidence = snapshot["response_evidence"] + assert isinstance(evidence, tuple) + assert evidence[-1]["payload_sha256"] == hashlib.sha256( + _generic_response(f"{VENDOR_DEVICE_ID}:wrong-session") + ).hexdigest() + assert evidence[-1]["payload_bytes"] > 0 + assert all("payload" not in item for item in evidence) + assert APPLICATION_KEY not in str(snapshot) + assert VENDOR_DEVICE_ID not in str(snapshot) + + def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None: transport = SyntheticAcceptanceTransport() start = PhysicalAcceptanceDialogueExecutor(