From a4accf0fb6eb37451789b262a50f0c074517c4e4 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 18 Jul 2026 14:37:02 +0300 Subject: [PATCH] feat(k1): wire dormant control lease --- README.md | 9 +- docs/01_IMPLEMENTATION_PLAN.md | 15 +- docs/05_K1_MQTT_STREAM_PROFILE.md | 10 +- docs/06_K1_LIVE_VIEWER.md | 11 +- docs/07_MISSION_CORE_MONOREPO.md | 9 +- docs/09_OBSERVATION_SESSIONS.md | 4 +- ...n-observation-runtime-and-k1-extraction.md | 3 +- .../0012-device-bound-k1-command-authority.md | 12 +- plugins/xgrids-k1/README.md | 8 + plugins/xgrids-k1/frontend/README.md | 2 + plugins/xgrids-k1/frontend/src/api.ts | 43 ++++ plugins/xgrids-k1/frontend/src/manifest.ts | 12 + plugins/xgrids-k1/plugin.manifest.json | 10 +- plugins/xgrids-k1/profiles/README.md | 9 +- .../profiles/fw-3.0.2/direct-lan.v1.json | 4 +- src/k1link/device_plugins/xgrids_k1/facade.py | 98 +++++++- .../protocol/application_execution.py | 211 ++++++++++++++++++ .../xgrids_k1/protocol/application_publish.py | 15 ++ tests/test_plugin_catalog.py | 7 +- tests/test_xgrids_acquisition_lifecycle.py | 112 ++++++++++ tests/test_xgrids_application_execution.py | 138 ++++++++++++ 21 files changed, 704 insertions(+), 38 deletions(-) create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py create mode 100644 tests/test_xgrids_application_execution.py diff --git a/README.md b/README.md index 6c09a60..dbe0487 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,12 @@ per-scanner profile. The whole pre-START order and START/STOP payloads are 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. +boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0 +wires a dormant coordinator into the facade: explicit shadow arm holds authority +for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke +it together with the orchestrator. No emission method or live sink exists, no UI +button claims device control, `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 diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index 3c9ce01..6ab2fd6 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 (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 | +| K1 application control | PAUSE (dormant runtime gate complete) — plugin v0.4.0 exposes operator-confirmed shadow arm/disarm/state; authority is held in a 15–300 s non-exportable lease and revoked on expiry, reprovision, acquisition preparation or shutdown; orchestrator is facade-owned, but no emission method/live sink exists and vendor writes remain false | | 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 @@ -41,9 +41,14 @@ 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. +write-disabled type that cannot call its injected sink. A dormant coordinator +is now wired into the XGRIDS facade and plugin manifest. Explicit operator arm +loads the Keychain value into a bounded 15–300 second process-memory lease; +expiry, disarm, reprovision, acquisition preparation and shutdown drop both the +lease and orchestrator. Public state contains only lease timing/booleans. There +is deliberately no batch-emission method, live MQTT sink or UI button. Keychain +item provisioning, a separately reviewed write-capable type and durable +post-stop save remain unproven. The physical button is 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; @@ -262,7 +267,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; -- operator-owned Keychain provisioning/execution lease, authorized command transport +- operator-owned Keychain item provisioning, separately reviewed emission 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. diff --git a/docs/05_K1_MQTT_STREAM_PROFILE.md b/docs/05_K1_MQTT_STREAM_PROFILE.md index bddceaa..86864c3 100644 --- a/docs/05_K1_MQTT_STREAM_PROFILE.md +++ b/docs/05_K1_MQTT_STREAM_PROFILE.md @@ -204,10 +204,14 @@ 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 exact 36-byte authority now has a +Publishing remains deliberately disabled. The exact 36-byte authority 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 +fallback. A facade-owned dormant coordinator can arm it only after explicit +operator confirmation, a connected/attested K1, idle runtime and no active +acquisition. Its non-exportable process-memory lease is limited to 15–300 +seconds and is revoked by expiry, disarm, reprovision, acquisition preparation +or shutdown. The current one-shot boundary is structurally write-disabled, +exposes no emission method 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 diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md index afdda92..813030d 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -148,9 +148,10 @@ 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. -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. +The fixed Keychain loader, bounded execution lease and dormant facade +orchestrator now exist. No emission method or live MQTT sink is installed, and +durable save completion after stop remains open, so `vendor_writes_enabled` is +false and the physical-button workflow is canonical. ## Automatic Rerun source and lifecycle @@ -332,8 +333,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 Keychain provisioning/execution-lease wiring, - response-gated one-shot behavior and a proven durable-save completion gate. + publisher needs reviewed Keychain item provisioning, a new write-capable + emission type, response-gated one-shot behavior and durable-save proof. - 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 diff --git a/docs/07_MISSION_CORE_MONOREPO.md b/docs/07_MISSION_CORE_MONOREPO.md index 4b6e66b..1845e75 100644 --- a/docs/07_MISSION_CORE_MONOREPO.md +++ b/docs/07_MISSION_CORE_MONOREPO.md @@ -148,9 +148,10 @@ 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. 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`. +exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator +are present, but there is no emission method or live MQTT sink. Keychain item +provisioning and stable-artifact proof after STOP remain unresolved, so +`vendor_writes_enabled=false`. ## Semantic lifecycle @@ -223,7 +224,7 @@ views. 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 operator-owned - Keychain execution-lease wiring and durable-save confirmation close its safety + Keychain item provisioning, reviewed emission and durable-save confirmation gate. Complex equipment will likely be assembled from separately useful component diff --git a/docs/09_OBSERVATION_SESSIONS.md b/docs/09_OBSERVATION_SESSIONS.md index 58b895a..b3f4210 100644 --- a/docs/09_OBSERVATION_SESSIONS.md +++ b/docs/09_OBSERVATION_SESSIONS.md @@ -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: operator-owned Keychain execution lease and durable save completion -remain separate physical/security gates. +publishing: operator-owned Keychain item provisioning, reviewed emission and +durable save remain separate physical/security gates. diff --git a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md index 0f9564e..23f9428 100644 --- a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md +++ b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md @@ -114,7 +114,8 @@ 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 -operator-owned Keychain execution-lease wiring and durable post-stop save evidence. +operator-owned Keychain item provisioning, reviewed emission 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 diff --git a/docs/adr/0012-device-bound-k1-command-authority.md b/docs/adr/0012-device-bound-k1-command-authority.md index 46eef8e..1d0b235 100644 --- a/docs/adr/0012-device-bound-k1-command-authority.md +++ b/docs/adr/0012-device-bound-k1-command-authority.md @@ -94,13 +94,21 @@ its injected transport sink can be called. Both shadow paths remain blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or automatic retry path exists. +`application_execution.py` adds the dormant facade boundary. An +operator-confirmed arm may retain authority for 15–300 seconds in a +non-exportable process-memory lease and construct the shadow orchestrator. The +lease and orchestrator are dropped together on expiry/disarm and also before +reprovision, acquisition preparation or shutdown. Plugin v0.4.0 exposes only +shadow arm/disarm/state actions and redacted snapshots. The coordinator has no +request-emission method and the installed sink is explicitly uninstalled; this +runtime wiring does not widen write authority. + ## Promotion gate A future publisher requires separate review and an operator-present physical acceptance: -1. provision the reviewed private authority into the fixed local Keychain item - and load it for one operator-owned execution lease; +1. provision the reviewed private authority into the fixed local Keychain item; 2. confirm battery/storage and select one K1 over BLE; 3. run the exact response-gated bootstrap and attest activated FW 3.0.2; 4. confirm READY identity against DeviceInfo and review shadow metadata; diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md index b61e8d3..eee3983 100644 --- a/plugins/xgrids-k1/README.md +++ b/plugins/xgrids-k1/README.md @@ -31,6 +31,8 @@ The plugin owns: - K1-specific operator instructions and compatibility tests. - the scoped React `device.connection` contribution and its BLE/Wi-Fi and acquisition pipeline UI. +- the dormant application-control facade boundary: explicit shadow arm/disarm, + a 15–300 second Keychain-backed in-memory authority lease, and redacted state. The plugin does not own: @@ -79,6 +81,12 @@ device I/O with: uv run python plugins/xgrids-k1/profile_loader.py ``` +Plugin v0.4.0 does not widen device authority. Its application-control +coordinator has no request-emission method, no live MQTT sink and no UI control; +`vendor_writes_enabled` remains false. Keychain item provisioning and a separate +review of any future write-capable transport are still required before a +physical command can be considered. + The optional owner-controlled iPhone/LixelGO observation tool lives under [`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a separate `uv` environment, writes only ignored evidence sessions, and remains a diff --git a/plugins/xgrids-k1/frontend/README.md b/plugins/xgrids-k1/frontend/README.md index 246f413..0d2c302 100644 --- a/plugins/xgrids-k1/frontend/README.md +++ b/plugins/xgrids-k1/frontend/README.md @@ -12,6 +12,8 @@ The contribution contains: start/stop and compatibility file replay; - plugin-local diagnostics, metrics, API state, lifecycle mapping, observation-source mapping and scoped styles; +- typed v0.4.0 shadow application-control state/arm/disarm contracts. No button + is rendered because the plugin has no live command transport; - `plugin.ts`, which binds the manifest `device.connection` component key to the runtime provider and connection view. diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index e81b3af..0ae19b7 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -82,6 +82,28 @@ export interface XgridsModelingControlSafety { publisher_installed: false; } +export interface XgridsApplicationControlExecution { + mode: "dormant-write-disabled"; + state: "disarmed" | "armed-shadow-only" | "expired" | "closed"; + lease?: { + state: "armed" | "expired" | "closed"; + remaining_seconds: number; + authority_cached: boolean; + exportable: false; + } | null; + orchestrator?: Record | null; + publisher: { + mode: "write-disabled"; + denied_attempts: number; + transport_calls: 0; + vendor_writes_enabled: false; + publisher_armed: false; + automatic_retry: false; + }; + live_transport_installed: false; + can_emit_requests: false; +} + export interface XgridsAcquisition { schema_version?: string; acquisition_id: string; @@ -205,6 +227,7 @@ export interface XgridsK1State { metrics?: XgridsK1Metrics; compatibility?: XgridsCompatibilityState | null; modeling_control_safety?: XgridsModelingControlSafety | null; + application_control_execution?: XgridsApplicationControlExecution | null; device_ref?: XgridsDeviceRef | null; device_session?: XgridsDeviceSession | null; acquisition?: XgridsAcquisition | null; @@ -306,6 +329,12 @@ export interface StopCameraPreviewRequest { generation: number; } +export interface ShadowApplicationControlArmRequest { + operator_confirmed: true; + lease_seconds?: number; + timezone_name: string; +} + export class ApiError extends Error { readonly status: number; @@ -453,6 +482,20 @@ export const xgridsK1Api = { updateViewerSettings(body: ViewerSettings): Promise { return invokeState(xgridsK1Actions.viewerSettingsUpdate, body); }, + + getShadowApplicationControlState(): Promise { + return invokeState(xgridsK1Actions.applicationControlShadowState); + }, + + armShadowApplicationControl( + body: ShadowApplicationControlArmRequest, + ): Promise { + return invokeState(xgridsK1Actions.applicationControlShadowArm, body); + }, + + disarmShadowApplicationControl(): Promise { + return invokeState(xgridsK1Actions.applicationControlShadowDisarm); + }, }; export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; diff --git a/plugins/xgrids-k1/frontend/src/manifest.ts b/plugins/xgrids-k1/frontend/src/manifest.ts index 59310ad..736dcd2 100644 --- a/plugins/xgrids-k1/frontend/src/manifest.ts +++ b/plugins/xgrids-k1/frontend/src/manifest.ts @@ -30,4 +30,16 @@ export const xgridsK1Actions = Object.freeze({ cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"), cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"), viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"), + applicationControlShadowState: requirePluginAction( + xgridsK1Manifest, + "application-control.shadow-state", + ), + applicationControlShadowArm: requirePluginAction( + xgridsK1Manifest, + "application-control.shadow-arm", + ), + applicationControlShadowDisarm: requirePluginAction( + xgridsK1Manifest, + "application-control.shadow-disarm", + ), }); diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index adffd63..ba49787 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -3,7 +3,7 @@ "kind": "DevicePlugin", "metadata": { "id": "nodedc.device.xgrids-lixelkity-k1", - "version": "0.3.0", + "version": "0.4.0", "displayName": "XGRIDS K1 Integration" }, "spec": { @@ -25,7 +25,8 @@ "network.mqtt.subscribe-private-lan", "network.rtsp.read-private-lan", "media.publish-local-browser", - "evidence.write-session-artifacts" + "evidence.write-session-artifacts", + "credential.keychain.read-local" ], "actions": [ { "id": "state.read", "mutating": false, "secretFields": [] }, @@ -45,7 +46,10 @@ { "id": "stream.stop", "mutating": true, "secretFields": [] }, { "id": "camera.preview.select", "mutating": true, "secretFields": [] }, { "id": "camera.preview.stop", "mutating": true, "secretFields": [] }, - { "id": "viewer.settings.update", "mutating": true, "secretFields": [] } + { "id": "viewer.settings.update", "mutating": true, "secretFields": [] }, + { "id": "application-control.shadow-state", "mutating": false, "secretFields": [] }, + { "id": "application-control.shadow-arm", "mutating": true, "secretFields": [] }, + { "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] } ], "models": [ { diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md index 2c3dec5..fddbc22 100644 --- a/plugins/xgrids-k1/profiles/README.md +++ b/plugins/xgrids-k1/profiles/README.md @@ -39,10 +39,11 @@ 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. 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. +read-only macOS Keychain loader, bounded in-memory lease and dormant facade +orchestrator now exist. No request-emission method or live MQTT sink exists; +operator-owned Keychain item provisioning, reviewed emission, 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 publisher must enforce the exact profile mapping (`2/1/0`, omitted diff --git a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json index 4615bab..ec3e889 100644 --- a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json +++ b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json @@ -359,7 +359,7 @@ }, "success_result_code": 302252033, "required_unresolved_context": [ - "operator-owned Keychain authority provisioning and execution-lease wiring", + "operator-owned Keychain item provisioning and reviewed request emission", "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": [ - "operator-owned Keychain authority provisioning and execution-lease wiring", + "operator-owned Keychain item provisioning and reviewed request emission", "save-completion and final-standby state mapping", "timeout and rollback contract" ], diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 4213a7b..a9c2988 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -45,6 +45,17 @@ from k1link.device_plugins.xgrids_k1.camera import ( ) from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4 from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock +from k1link.device_plugins.xgrids_k1.protocol.application_authority import ( + MacOSKeychainApplicationAuthorityLoader, +) +from k1link.device_plugins.xgrids_k1.protocol.application_execution import ( + ApplicationAuthorityLoader, + DormantApplicationControlCoordinator, +) +from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( + UninstalledApplicationPublishSink, + WriteDisabledOneShotPublisher, +) from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( LiveModelingControlSafety, @@ -74,7 +85,7 @@ from k1link.web.plugin_runtime import ( ) XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" -XGRIDS_K1_PLUGIN_VERSION = "0.3.0" +XGRIDS_K1_PLUGIN_VERSION = "0.4.0" XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1" XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" @@ -123,6 +134,9 @@ ACTION_STREAM_STOP = "stream.stop" ACTION_CAMERA_PREVIEW_SELECT = "camera.preview.select" ACTION_CAMERA_PREVIEW_STOP = "camera.preview.stop" ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update" +ACTION_APPLICATION_CONTROL_SHADOW_STATE = "application-control.shadow-state" +ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm" +ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm" RequestedStreamId = Literal[ "spatial.point-cloud.live", @@ -247,10 +261,25 @@ class ViewerSettingsRequest(StrictRequest): show_grid: bool = True +class ShadowApplicationControlArmRequest(StrictRequest): + operator_confirmed: Literal[True] + lease_seconds: float = Field(default=60.0, ge=15.0, le=300.0) + timezone_name: str = Field( + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*$", + ) + + class XgridsK1CompatibilityService: """The proven K1 runtime kept intact behind the plugin facade.""" - def __init__(self, repository_root: Path) -> None: + def __init__( + self, + repository_root: Path, + *, + application_authority_loader: ApplicationAuthorityLoader | None = None, + ) -> None: self.repository_root = repository_root.resolve() self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root) self._lock = threading.Lock() @@ -284,6 +313,10 @@ class XgridsK1CompatibilityService: # The host-owned visual runtime receives the vendor normalizer # explicitly. There is no implicit K1 decoder in the visual layer. self._modeling_control_safety = LiveModelingControlSafety() + self._application_control = DormantApplicationControlCoordinator( + application_authority_loader or MacOSKeychainApplicationAuthorityLoader(), + WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()), + ) self.runtime = VisualizationRuntime( normalizer=normalize_k1_message, message_observer=self._observe_runtime_message, @@ -295,6 +328,7 @@ class XgridsK1CompatibilityService: @_serialized_acquisition_access def state(self) -> dict[str, Any]: + application_control = self._application_control.snapshot().as_dict() runtime = self.runtime.snapshot() camera_preview = self.camera_preview.snapshot() self._reconcile_acquisition(runtime, camera_preview) @@ -386,6 +420,7 @@ class XgridsK1CompatibilityService: ), }, "modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(), + "application_control_execution": application_control, "device_ref": ( { "device_id": device_id, @@ -549,6 +584,7 @@ class XgridsK1CompatibilityService: self._provisioning_active = True # A reprovision can replace both the device session and its address. + self._application_control.disarm() # Revoke preview/producer state only after the active-acquisition # guard; a rejected network write must never stop evidence capture. self.camera_preview.stop_current() @@ -678,8 +714,43 @@ class XgridsK1CompatibilityService: } return self.state() + @_serialized_acquisition_access + def arm_application_control_shadow( + self, + request: ShadowApplicationControlArmRequest, + ) -> dict[str, Any]: + """Load a short authority lease without creating a publish path.""" + + with self._lock: + if self._provisioning_active: + raise RuntimeError("нельзя вооружать shadow control во время настройки Wi-Fi") + if self._k1_ip is None or self._selected_device_id is None: + raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi") + if self._compatibility_attestation is None: + raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён") + acquisition = self._acquisition + if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: + raise RuntimeError( + "нельзя вооружать shadow control во время активной acquisition-сессии" + ) + if self.runtime.snapshot().get("source_mode") != "idle": + raise RuntimeError("нельзя вооружать shadow control при активном live/replay source") + + self._application_control.arm( + ttl_seconds=request.lease_seconds, + epoch_seconds=int(time.time()), + timezone_name=request.timezone_name, + ) + return self.state() + + @_serialized_acquisition_access + def disarm_application_control_shadow(self) -> dict[str, Any]: + self._application_control.disarm() + return self.state() + @_serialized_acquisition_access def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: + self._application_control.disarm() requested_streams = _validated_requested_streams(request) target = request.host or self.state()["k1_ip"] if not isinstance(target, str) or not target: @@ -1266,6 +1337,7 @@ class XgridsK1CompatibilityService: @_serialized_acquisition_access def close(self) -> None: + self._application_control.close() with self._lock: acquisition = self._acquisition close_active_acquisition = ( @@ -1784,6 +1856,13 @@ class XgridsK1ServicePort(Protocol): def verify_connection(self) -> dict[str, Any]: ... + def arm_application_control_shadow( + self, + request: ShadowApplicationControlArmRequest, + ) -> dict[str, Any]: ... + + def disarm_application_control_shadow(self) -> dict[str, Any]: ... + def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: ... def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: ... @@ -1835,6 +1914,9 @@ class XgridsK1PluginFacade: ACTION_CAMERA_PREVIEW_SELECT, ACTION_CAMERA_PREVIEW_STOP, ACTION_VIEWER_SETTINGS_UPDATE, + ACTION_APPLICATION_CONTROL_SHADOW_STATE, + ACTION_APPLICATION_CONTROL_SHADOW_ARM, + ACTION_APPLICATION_CONTROL_SHADOW_DISARM, } ) @@ -1881,6 +1963,18 @@ class XgridsK1PluginFacade: if action_id == ACTION_CONNECTION_VERIFY: EmptyRequest.model_validate(payload) return await asyncio.to_thread(self.service.verify_connection) + if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE: + EmptyRequest.model_validate(payload) + return await asyncio.to_thread(self.service.state) + if action_id == ACTION_APPLICATION_CONTROL_SHADOW_ARM: + arm_request = ShadowApplicationControlArmRequest.model_validate(payload) + return await asyncio.to_thread( + self.service.arm_application_control_shadow, + arm_request, + ) + if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM: + EmptyRequest.model_validate(payload) + return await asyncio.to_thread(self.service.disarm_application_control_shadow) if action_id == ACTION_ACQUISITION_PREPARE: prepare_request = PrepareAcquisitionRequest.model_validate(payload) return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py new file mode 100644 index 0000000..dd0cfa9 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Protocol + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, + ShadowApplicationBootstrapOrchestrator, +) +from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( + WriteDisabledOneShotPublisher, +) + +MIN_AUTHORITY_LEASE_SECONDS = 15.0 +MAX_AUTHORITY_LEASE_SECONDS = 300.0 + + +class ApplicationControlExecutionError(RuntimeError): + """The dormant application-control execution gate failed closed.""" + + +class ApplicationAuthorityLoader(Protocol): + def load(self) -> ApplicationControlAuthority: ... + + +class MonotonicClock(Protocol): + def __call__(self) -> float: ... + + +@dataclass(frozen=True, slots=True) +class ApplicationAuthorityLeaseSnapshot: + state: str + remaining_seconds: float + authority_cached: bool + exportable: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "state": self.state, + "remaining_seconds": self.remaining_seconds, + "authority_cached": self.authority_cached, + "exportable": self.exportable, + } + + +class ApplicationAuthorityLease: + """Keep authority in process memory only for one short control attempt.""" + + def __init__( + self, + loader: ApplicationAuthorityLoader, + *, + ttl_seconds: float, + monotonic: MonotonicClock = time.monotonic, + ) -> None: + if not isinstance(ttl_seconds, (int, float)) or isinstance(ttl_seconds, bool): + raise ApplicationControlExecutionError("authority lease TTL must be numeric") + if not MIN_AUTHORITY_LEASE_SECONDS <= ttl_seconds <= MAX_AUTHORITY_LEASE_SECONDS: + raise ApplicationControlExecutionError( + "authority lease TTL is outside the reviewed 15-300 second range" + ) + self._lock = threading.Lock() + self._monotonic = monotonic + self._authority: ApplicationControlAuthority | None = loader.load() + self._expires_at = monotonic() + float(ttl_seconds) + self._state = "armed" + + def authority(self) -> ApplicationControlAuthority: + with self._lock: + self._expire_locked() + if self._state != "armed" or self._authority is None: + raise ApplicationControlExecutionError("application authority lease is not armed") + return self._authority + + def snapshot(self) -> ApplicationAuthorityLeaseSnapshot: + with self._lock: + self._expire_locked() + remaining = max(0.0, self._expires_at - self._monotonic()) + return ApplicationAuthorityLeaseSnapshot( + state=self._state, + remaining_seconds=round(remaining, 3), + authority_cached=self._authority is not None, + ) + + def close(self) -> None: + with self._lock: + self._authority = None + if self._state != "expired": + self._state = "closed" + + def _expire_locked(self) -> None: + if self._state == "armed" and self._monotonic() >= self._expires_at: + self._authority = None + self._state = "expired" + + +@dataclass(frozen=True, slots=True) +class DormantApplicationControlSnapshot: + state: str + lease: ApplicationAuthorityLeaseSnapshot | None + orchestrator: dict[str, object] | None + publisher: dict[str, object] + live_transport_installed: bool = False + can_emit_requests: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "mode": "dormant-write-disabled", + "state": self.state, + "lease": self.lease.as_dict() if self.lease is not None else None, + "orchestrator": self.orchestrator, + "publisher": self.publisher, + "live_transport_installed": self.live_transport_installed, + "can_emit_requests": self.can_emit_requests, + } + + +class DormantApplicationControlCoordinator: + """Facade-owned authority/orchestrator lifecycle with no emission method.""" + + def __init__( + self, + loader: ApplicationAuthorityLoader, + publisher: WriteDisabledOneShotPublisher, + *, + monotonic: MonotonicClock = time.monotonic, + ) -> None: + self._lock = threading.Lock() + self._loader = loader + self._publisher = publisher + self._monotonic = monotonic + self._lease: ApplicationAuthorityLease | None = None + self._orchestrator: ShadowApplicationBootstrapOrchestrator | None = None + self._state = "disarmed" + + def arm( + self, + *, + ttl_seconds: float, + epoch_seconds: int, + timezone_name: str, + ) -> DormantApplicationControlSnapshot: + with self._lock: + self._expire_locked() + if self._lease is not None or self._orchestrator is not None: + raise ApplicationControlExecutionError( + "application control coordinator is already armed" + ) + lease = ApplicationAuthorityLease( + self._loader, + ttl_seconds=ttl_seconds, + monotonic=self._monotonic, + ) + try: + orchestrator = ShadowApplicationBootstrapOrchestrator( + lease.authority(), + epoch_seconds=epoch_seconds, + timezone_name=timezone_name, + ) + except Exception: + lease.close() + raise + self._lease = lease + self._orchestrator = orchestrator + self._state = "armed-shadow-only" + return self._snapshot_locked() + + def disarm(self) -> DormantApplicationControlSnapshot: + with self._lock: + self._close_locked("disarmed") + return self._snapshot_locked() + + def snapshot(self) -> DormantApplicationControlSnapshot: + with self._lock: + self._expire_locked() + return self._snapshot_locked() + + def close(self) -> None: + with self._lock: + self._close_locked("closed") + + def _expire_locked(self) -> None: + if self._lease is None: + return + lease_snapshot = self._lease.snapshot() + if lease_snapshot.state == "expired": + self._close_locked("expired") + + def _close_locked(self, state: str) -> None: + if self._lease is not None: + self._lease.close() + self._lease = None + self._orchestrator = None + self._state = state + + def _snapshot_locked(self) -> DormantApplicationControlSnapshot: + lease_snapshot = self._lease.snapshot() if self._lease is not None else None + if lease_snapshot is not None and lease_snapshot.state == "expired": + self._close_locked("expired") + lease_snapshot = None + orchestrator_snapshot = ( + self._orchestrator.snapshot().as_dict() if self._orchestrator is not None else None + ) + return DormantApplicationControlSnapshot( + state=self._state, + lease=lease_snapshot, + orchestrator=orchestrator_snapshot, + publisher=self._publisher.snapshot().as_dict(), + ) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py index d7db0a1..5a5673b 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py @@ -28,6 +28,21 @@ class ApplicationPublishSink(Protocol): ) -> None: ... +class UninstalledApplicationPublishSink: + """Explicit placeholder proving that no live MQTT sink is installed.""" + + def publish( + self, + *, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: + del topic, payload, qos, retain + raise VendorWritesDisabledError("live application publish sink is not installed") + + @dataclass(frozen=True, slots=True) class OneShotPublishEnvelope: topic: str diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 8faec00..e75d748 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None: item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" ) assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2" - assert plugin["metadata"]["version"] == "0.3.0" + assert plugin["metadata"]["version"] == "0.4.0" assert plugin["spec"]["hostApiRange"] == "v1alpha2" assert plugin["spec"]["compatibilityProfiles"] == [ { @@ -116,10 +116,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None: "camera.preview.select", "camera.preview.stop", "viewer.settings.update", + "application-control.shadow-state", + "application-control.shadow-arm", + "application-control.shadow-disarm", } <= action_ids assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == { "pluginId": "nodedc.device.xgrids-lixelkity-k1", - "pluginVersion": "0.3.0", + "pluginVersion": "0.4.0", "id": "xgrids.lixelkity-k1", "vendor": "XGRIDS", "displayName": "XGRIDS LixelKity K1", diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 81bee10..2683236 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -17,10 +17,14 @@ from k1link.device_plugins.xgrids_k1.facade import ( CompatibilityAttestationRequest, ConnectRequest, PrepareAcquisitionRequest, + ShadowApplicationControlArmRequest, StartAcquisitionRequest, StopAcquisitionRequest, XgridsK1CompatibilityService, ) +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, +) ATTESTATION = CompatibilityAttestationRequest( firmware_version="3.0.2", @@ -30,6 +34,16 @@ ATTESTATION = CompatibilityAttestationRequest( PRIMARY_TEST_CREDENTIAL = "x" * 24 SECONDARY_TEST_CREDENTIAL = "y" * 24 PROJECT_NAME = "K1 lifecycle test" +PRIVATE_APPLICATION_AUTHORITY = "11111111-2222-3333-4444-555555555555" + + +class FakeApplicationAuthorityLoader: + def __init__(self) -> None: + self.calls = 0 + + def load(self) -> ApplicationControlAuthority: + self.calls += 1 + return ApplicationControlAuthority(openapi_key=PRIVATE_APPLICATION_AUTHORITY) class FakeVisualizationRuntime: @@ -148,6 +162,104 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non ) +def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport( + tmp_path: Path, +) -> None: + loader = FakeApplicationAuthorityLoader() + service = XgridsK1CompatibilityService( + tmp_path, + application_authority_loader=loader, + ) + runtime = FakeVisualizationRuntime() + service.runtime = runtime # type: ignore[assignment] + service._selected_device_id = "test-ble-transport" # noqa: SLF001 + service._k1_ip = "192.168.1.20" # noqa: SLF001 + service._compatibility_attestation = { # noqa: SLF001 + "firmware_version": "3.0.2", + "topology": "direct-lan", + "basis": "operator-attested", + "observed_at": "2026-07-18T00:00:00Z", + } + + state = service.arm_application_control_shadow( + ShadowApplicationControlArmRequest( + operator_confirmed=True, + lease_seconds=60.0, + timezone_name="Europe/Moscow", + ) + ) + + execution = state["application_control_execution"] + assert loader.calls == 1 + assert execution["state"] == "armed-shadow-only" + assert execution["lease"]["authority_cached"] is True + assert execution["can_emit_requests"] is False + assert execution["live_transport_installed"] is False + assert execution["publisher"]["vendor_writes_enabled"] is False + assert execution["publisher"]["transport_calls"] == 0 + assert PRIVATE_APPLICATION_AUTHORITY not in str(state) + + disarmed = service.disarm_application_control_shadow() + assert disarmed["application_control_execution"]["state"] == "disarmed" + assert disarmed["application_control_execution"]["lease"] is None + + +def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read( + tmp_path: Path, +) -> None: + loader = FakeApplicationAuthorityLoader() + service = XgridsK1CompatibilityService( + tmp_path, + application_authority_loader=loader, + ) + service.runtime = FakeVisualizationRuntime() # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="подключите K1"): + service.arm_application_control_shadow( + ShadowApplicationControlArmRequest( + operator_confirmed=True, + timezone_name="Europe/Moscow", + ) + ) + + assert loader.calls == 0 + + +def test_shadow_arm_contract_requires_explicit_operator_confirmation() -> None: + with pytest.raises(ValidationError): + ShadowApplicationControlArmRequest.model_validate({"timezone_name": "Europe/Moscow"}) + + +def test_prepare_acquisition_revokes_existing_shadow_authority_lease(tmp_path: Path) -> None: + loader = FakeApplicationAuthorityLoader() + service = XgridsK1CompatibilityService( + tmp_path, + application_authority_loader=loader, + ) + service.runtime = FakeVisualizationRuntime() # type: ignore[assignment] + service._selected_device_id = "test-ble-transport" # noqa: SLF001 + service._k1_ip = "192.168.1.20" # noqa: SLF001 + service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001 + service.arm_application_control_shadow( + ShadowApplicationControlArmRequest( + operator_confirmed=True, + timezone_name="Europe/Moscow", + ) + ) + + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + + assert prepared["application_control_execution"]["state"] == "disarmed" + assert prepared["application_control_execution"]["lease"] is None + + def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None: service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( diff --git a/tests/test_xgrids_application_execution.py b/tests/test_xgrids_application_execution.py new file mode 100644 index 0000000..580df1a --- /dev/null +++ b/tests/test_xgrids_application_execution.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import pytest + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, +) +from k1link.device_plugins.xgrids_k1.protocol.application_execution import ( + ApplicationAuthorityLease, + ApplicationControlExecutionError, + DormantApplicationControlCoordinator, +) +from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( + WriteDisabledOneShotPublisher, +) + +PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555" + + +class FakeClock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + +class FakeLoader: + def __init__(self) -> None: + self.calls = 0 + + def load(self) -> ApplicationControlAuthority: + self.calls += 1 + return ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY) + + +class ForbiddenSink: + def publish( + self, + *, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: + del topic, payload, qos, retain + raise AssertionError("write-disabled coordinator touched its sink") + + +def test_authority_lease_expires_and_drops_its_cached_reference() -> None: + clock = FakeClock() + loader = FakeLoader() + lease = ApplicationAuthorityLease(loader, ttl_seconds=30.0, monotonic=clock) + + assert loader.calls == 1 + assert lease.snapshot().state == "armed" + assert lease.snapshot().authority_cached + assert PRIVATE_AUTHORITY not in repr(lease.snapshot()) + + clock.now = 130.0 + snapshot = lease.snapshot() + assert snapshot.state == "expired" + assert not snapshot.authority_cached + assert snapshot.remaining_seconds == 0.0 + with pytest.raises(ApplicationControlExecutionError, match="not armed"): + lease.authority() + + +@pytest.mark.parametrize("ttl_seconds", [14.999, 300.001, True]) +def test_authority_lease_rejects_unreviewed_ttl(ttl_seconds: object) -> None: + with pytest.raises(ApplicationControlExecutionError): + ApplicationAuthorityLease( # type: ignore[arg-type] + FakeLoader(), + ttl_seconds=ttl_seconds, + ) + + +def test_dormant_coordinator_arms_once_and_exposes_no_emission_path() -> None: + clock = FakeClock() + loader = FakeLoader() + coordinator = DormantApplicationControlCoordinator( + loader, + WriteDisabledOneShotPublisher(ForbiddenSink()), + monotonic=clock, + ) + + armed = coordinator.arm( + ttl_seconds=60.0, + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + public = armed.as_dict() + assert public["state"] == "armed-shadow-only" + assert public["can_emit_requests"] is False + assert public["live_transport_installed"] is False + assert public["publisher"] == { + "mode": "write-disabled", + "denied_attempts": 0, + "transport_calls": 0, + "vendor_writes_enabled": False, + "publisher_armed": False, + "automatic_retry": False, + } + assert PRIVATE_AUTHORITY not in repr(armed) + assert PRIVATE_AUTHORITY not in str(public) + with pytest.raises(ApplicationControlExecutionError, match="already armed"): + coordinator.arm( + ttl_seconds=60.0, + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + disarmed = coordinator.disarm().as_dict() + assert disarmed["state"] == "disarmed" + assert disarmed["lease"] is None + assert disarmed["orchestrator"] is None + assert loader.calls == 1 + + +def test_coordinator_expiry_drops_lease_and_orchestrator_together() -> None: + clock = FakeClock() + coordinator = DormantApplicationControlCoordinator( + FakeLoader(), + WriteDisabledOneShotPublisher(ForbiddenSink()), + monotonic=clock, + ) + coordinator.arm( + ttl_seconds=15.0, + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + clock.now = 115.0 + snapshot = coordinator.snapshot().as_dict() + assert snapshot["state"] == "expired" + assert snapshot["lease"] is None + assert snapshot["orchestrator"] is None + assert snapshot["can_emit_requests"] is False