From d655d6998dfab1f8692d3d610a5a62d084b1a39b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 2 Sep 2026 00:57:27 +0300 Subject: [PATCH] feat(observatory): attest recording equipment and capture profiles --- .../src/core/observation/sessionArchive.ts | 120 ++++++ .../portableLaboratorySetupDecoder.ts | 8 +- .../test/observationSessions.test.mjs | 1 + .../test/observatoryLaboratorySetups.test.mjs | 1 + config/observatory-equipment-models.json | 22 + ...observatory-recorded-capture-profiles.json | 43 ++ .../canonical_recorded_migration.py | 35 ++ .../observatory/portable_run_definitions.py | 9 +- .../observatory/portable_setup_projection.py | 53 ++- src/k1link/observatory/source_admission.py | 9 +- src/k1link/sessions/equipment.py | 380 ++++++++++++++++++ src/k1link/sessions/models.py | 53 ++- src/k1link/sessions/store.py | 170 +++++++- src/k1link/web/session_api.py | 5 + tests/test_observatory_equipment_registry.py | 209 ++++++++++ ...t_observatory_portable_setup_projection.py | 12 +- tests/test_session_api.py | 1 + 17 files changed, 1089 insertions(+), 42 deletions(-) create mode 100644 config/observatory-equipment-models.json create mode 100644 config/observatory-recorded-capture-profiles.json create mode 100644 src/k1link/laboratory/canonical_recorded_migration.py create mode 100644 src/k1link/sessions/equipment.py create mode 100644 tests/test_observatory_equipment_registry.py diff --git a/apps/control-station/src/core/observation/sessionArchive.ts b/apps/control-station/src/core/observation/sessionArchive.ts index 9f2c731..78ab5dc 100644 --- a/apps/control-station/src/core/observation/sessionArchive.ts +++ b/apps/control-station/src/core/observation/sessionArchive.ts @@ -48,10 +48,21 @@ export interface ObservationSessionSummary { modalities: readonly string[]; durationSeconds: number; replayable: boolean; + captureAttestation: ObservationCaptureAttestation | null; preparation: ObservationSessionCatalogPreparation | null; lab: ObservationLabInstance | null; } +export interface ObservationCaptureAttestation { + readonly equipmentModelId: string; + readonly equipmentModelSha256: string; + readonly equipmentDisplayName: string; + readonly captureProfileId: string; + readonly captureProfileSha256: string; + readonly captureAttestationSha256: string; + readonly state: "attested" | "attested-with-legacy-evidence" | "unknown"; +} + export interface ObservationSessionCatalog { items: readonly ObservationSessionSummary[]; } @@ -168,6 +179,7 @@ const ITEM_KEYS = new Set([ "modalities", "duration_seconds", "replayable", + "capture_attestation", "preparation", "lab", ]); @@ -187,6 +199,22 @@ const LAB_V3_KEYS = new Set([ ...LAB_V2_KEYS, "calculation_profile", ]); +const CAPTURE_ATTESTATION_KEYS = new Set([ + "schema_version", + "equipment_model", + "capture_profile", + "capture_attestation_sha256", + "state", +]); +const EQUIPMENT_MODEL_KEYS = new Set([ + "equipment_model_id", + "equipment_model_sha256", + "display_name", +]); +const CAPTURE_PROFILE_KEYS = new Set([ + "capture_profile_id", + "capture_profile_sha256", +]); const CATALOG_PREPARATION_KEYS = new Set([ "preparation_id", "state", @@ -400,11 +428,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary { modalities: requireModalities(value.modalities), durationSeconds: value.duration_seconds, replayable: value.replayable, + captureAttestation: decodeCaptureAttestation(value.capture_attestation, id), preparation: decodeCatalogPreparation(value.preparation, id), lab: decodeLabInstance(value.lab, id), }; } +function decodeCaptureAttestation( + value: unknown, + sessionId: string, +): ObservationCaptureAttestation | null { + if (value === null || value === undefined) return null; + if (!isRecord(value)) { + throw new ObservationSessionContractError( + `Аттестация записи ${sessionId} должна быть объектом или null.`, + ); + } + assertExactKeys(value, CAPTURE_ATTESTATION_KEYS, `Аттестация записи ${sessionId}`); + if (value.schema_version !== "missioncore.recorded-capture-attestation/v1") { + throw new ObservationSessionContractError( + `Аттестация записи ${sessionId} имеет неизвестную схему.`, + ); + } + if (!isRecord(value.equipment_model) || !isRecord(value.capture_profile)) { + throw new ObservationSessionContractError( + `Аттестация записи ${sessionId} не содержит оборудование или профиль записи.`, + ); + } + assertExactKeys( + value.equipment_model, + EQUIPMENT_MODEL_KEYS, + `Оборудование записи ${sessionId}`, + ); + assertExactKeys( + value.capture_profile, + CAPTURE_PROFILE_KEYS, + `Профиль записи ${sessionId}`, + ); + const equipmentModelId = requireString( + value.equipment_model.equipment_model_id, + "equipment_model_id", + 128, + ); + const equipmentModelSha256 = requireString( + value.equipment_model.equipment_model_sha256, + "equipment_model_sha256", + 64, + ); + const captureProfileId = requireString( + value.capture_profile.capture_profile_id, + "capture_profile_id", + 128, + ); + const captureProfileSha256 = requireString( + value.capture_profile.capture_profile_sha256, + "capture_profile_sha256", + 64, + ); + const captureAttestationSha256 = requireString( + value.capture_attestation_sha256, + "capture_attestation_sha256", + 64, + ); + if ( + !SAFE_ID.test(equipmentModelId) + || !SAFE_ID.test(captureProfileId) + || !SHA256.test(equipmentModelSha256) + || !SHA256.test(captureProfileSha256) + || !SHA256.test(captureAttestationSha256) + ) { + throw new ObservationSessionContractError( + `Аттестация записи ${sessionId} содержит некорректную immutable identity.`, + ); + } + if ( + value.state !== "attested" + && value.state !== "attested-with-legacy-evidence" + && value.state !== "unknown" + ) { + throw new ObservationSessionContractError( + `Аттестация записи ${sessionId} содержит неизвестное состояние.`, + ); + } + return { + equipmentModelId, + equipmentModelSha256, + equipmentDisplayName: requireString( + value.equipment_model.display_name, + "equipment display_name", + 128, + ), + captureProfileId, + captureProfileSha256, + captureAttestationSha256, + state: value.state, + }; +} + function decodeLabInstance( value: unknown, sessionId: string, diff --git a/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts b/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts index 9758394..0483218 100644 --- a/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts +++ b/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts @@ -43,7 +43,7 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup { const compatibility = record(row.source_compatibility, "portable source_compatibility"); exactKeys( compatibility, - ["compatible", "outcome", "reason"], + ["compatible", "outcome", "reason", "reason_code"], "portable source_compatibility", ); const compatible = boolean(compatibility.compatible, "portable compatible"); @@ -53,6 +53,10 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup { "portable compatibility outcome", ); const compatibilityReason = text(compatibility.reason, "portable compatibility reason"); + const compatibilityReasonCode = text( + compatibility.reason_code, + "portable compatibility reason_code", + ); const executor = record(row.executor, "portable executor"); exactKeys( @@ -134,7 +138,7 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup { compatible, reasons: compatible ? [] - : [{ code: "source-capability-blocked", message: compatibilityReason }], + : [{ code: compatibilityReasonCode, message: compatibilityReason }], }, executor: { contourId: text(executor.contour_id, "portable executor contour_id"), diff --git a/apps/control-station/test/observationSessions.test.mjs b/apps/control-station/test/observationSessions.test.mjs index e95e0f4..7acfb8b 100644 --- a/apps/control-station/test/observationSessions.test.mjs +++ b/apps/control-station/test/observationSessions.test.mjs @@ -198,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo modalities: ["point-cloud", "pose"], durationSeconds: 1_450.744, replayable: true, + captureAttestation: null, preparation: null, lab: null, }]); diff --git a/apps/control-station/test/observatoryLaboratorySetups.test.mjs b/apps/control-station/test/observatoryLaboratorySetups.test.mjs index c4c98c8..99e25af 100644 --- a/apps/control-station/test/observatoryLaboratorySetups.test.mjs +++ b/apps/control-station/test/observatoryLaboratorySetups.test.mjs @@ -116,6 +116,7 @@ function portableSetup() { source_compatibility: { outcome: "pass", compatible: true, + reason_code: "source-compatible", reason: "Запись соответствует требованиям EoMT + DDRNet.", }, executor: { diff --git a/config/observatory-equipment-models.json b/config/observatory-equipment-models.json new file mode 100644 index 0000000..71887ea --- /dev/null +++ b/config/observatory-equipment-models.json @@ -0,0 +1,22 @@ +{ + "schema_version": "missioncore.equipment-model-registry/v1", + "models": [ + { + "schema_version": "missioncore.equipment-model/v1", + "equipment_model_id": "xgrids.lixelkity-k1", + "equipment_model_version": 1, + "plugin_id": "nodedc.device.xgrids-lixelkity-k1", + "vendor": "XGRIDS", + "display_name": "XGRIDS LixelKity K1", + "category": "mobile-lidar-scanner", + "capability_ids": [ + "recorded.camera.video", + "recorded.lidar.point-cloud", + "recorded.pose.trajectory" + ], + "authority": { + "commands_enabled": false + } + } + ] +} diff --git a/config/observatory-recorded-capture-profiles.json b/config/observatory-recorded-capture-profiles.json new file mode 100644 index 0000000..83d507c --- /dev/null +++ b/config/observatory-recorded-capture-profiles.json @@ -0,0 +1,43 @@ +{ + "schema_version": "missioncore.recorded-capture-profile-registry/v1", + "profiles": [ + { + "schema_version": "missioncore.recorded-capture-profile/v1", + "capture_profile_id": "xgrids-k1.viewer-live.fw-3.0.2.v1", + "capture_profile_version": 1, + "equipment_model_id": "xgrids.lixelkity-k1", + "equipment_model_sha256": "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa", + "plugin_id": "nodedc.device.xgrids-lixelkity-k1", + "archive_id": "xgrids-k1.viewer-live.evidence", + "firmware_compatibility_profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2", + "modalities": [ + "point-cloud", + "trajectory", + "video" + ], + "semantic_channels": [ + "camera.video.recorded", + "spatial.point-cloud.recorded", + "spatial.pose.recorded" + ], + "camera_media": { + "source_id": "sensor.camera.right", + "media_type": "video/mp4; codecs=\"avc1.641028\"", + "width": 800, + "height": 600, + "initialization_sha256": "e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38" + }, + "calibration": { + "slot_id": "camera_1", + "sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9" + }, + "media_epoch_policy": "exactly-one-complete-epoch", + "seekable": true, + "adapter": { + "adapter_id": "xgrids-k1-recorded-observatory-v2", + "version": 2, + "adapter_sha256": "4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de" + } + } + ] +} diff --git a/src/k1link/laboratory/canonical_recorded_migration.py b/src/k1link/laboratory/canonical_recorded_migration.py new file mode 100644 index 0000000..3e7006c --- /dev/null +++ b/src/k1link/laboratory/canonical_recorded_migration.py @@ -0,0 +1,35 @@ +"""Exact metadata gate for the one historical recorded-LAB migration. + +This compatibility check is intentionally outside the generic Session catalog: +new device plugins and equipment profiles must not add concrete vocabulary to +the shared observation hot path. +""" + +from __future__ import annotations + +from typing import Final + +_DISPLAY_NAME: Final = "RAVNOVES004TREE · полный маршрут восприятия" +_DURATION_SECONDS: Final = 718.0 +_RUN_CREATED_AT_UTC: Final = "2026-08-29T18:05:11.329061+00:00" +_PLUGIN_ID: Final = "nodedc.device.xgrids-lixelkity-k1" +_ARCHIVE_ID: Final = "xgrids-k1.viewer-live.evidence" + + +def matches_historical_recorded_projection( + *, + display_name: object, + duration_seconds: object, + run_created_at_utc: object, + source_plugin_id: object, + source_archive_id: object, +) -> bool: + """Return whether legacy rows match the only supported rolling upgrade.""" + + return ( + display_name == _DISPLAY_NAME + and duration_seconds == _DURATION_SECONDS + and run_created_at_utc == _RUN_CREATED_AT_UTC + and source_plugin_id == _PLUGIN_ID + and source_archive_id == _ARCHIVE_ID + ) diff --git a/src/k1link/observatory/portable_run_definitions.py b/src/k1link/observatory/portable_run_definitions.py index a1cf39f..82ce9fe 100644 --- a/src/k1link/observatory/portable_run_definitions.py +++ b/src/k1link/observatory/portable_run_definitions.py @@ -124,8 +124,8 @@ class ObservationOnlyAuthority: @dataclass(frozen=True, slots=True) -class K1RecordedSourceRequirements: - """Typed capability matcher for one class of recorded K1 sessions.""" +class RecordedSourceRequirements: + """Typed capability matcher for one exact recorded capture contract.""" plugin_id: str archive_id: str @@ -206,6 +206,11 @@ class K1RecordedSourceRequirements: } +# Compatibility name retained for promoted package readers. Canonical JSON +# and all digests remain byte-for-byte unchanged. +K1RecordedSourceRequirements = RecordedSourceRequirements + + @dataclass(frozen=True, slots=True) class PortableSourceAdapterIdentity: adapter_id: str diff --git a/src/k1link/observatory/portable_setup_projection.py b/src/k1link/observatory/portable_setup_projection.py index 34eff43..544ecec 100644 --- a/src/k1link/observatory/portable_setup_projection.py +++ b/src/k1link/observatory/portable_setup_projection.py @@ -26,6 +26,7 @@ from k1link.observatory.source_admission import ( PortableRecordedSourceCapability, PortableSourceAdmissionError, ) +from k1link.sessions.equipment import EquipmentCaptureRegistry from k1link.sessions.models import SessionSummary PORTABLE_LABORATORY_SETUP_CATALOG_SCHEMA: Final = ( @@ -93,12 +94,14 @@ type PortableSourceCapabilityProbe = Callable[[str], PortableRecordedSourceCapab class _SourceCompatibility: compatible: bool capability: PortableRecordedSourceCapability | None + reason_code: str reason: str def as_dict(self) -> dict[str, object]: return { "outcome": "pass" if self.compatible else "blocked", "compatible": self.compatible, + "reason_code": self.reason_code, "reason": self.reason, } @@ -112,12 +115,14 @@ class PortableSetupProjector: registry: PortableRunDefinitionRegistry, capability_probe: PortableDefinitionCapabilityProbe, dispatch_available: bool = False, + equipment_capture_registry: EquipmentCaptureRegistry | None = None, ) -> None: if not hasattr(capability_probe, "probe"): raise PortableSetupProjectionError("portable source capability probe is unavailable") self._registry = registry self._capability_probe = capability_probe self._dispatch_available = dispatch_available + self._equipment_capture_registry = equipment_capture_registry for definition in registry.definitions: _validate_model_presentation(definition) if definition.authority.as_dict() != _OBSERVATION_ONLY_AUTHORITY: @@ -159,7 +164,7 @@ class PortableSetupProjector: except PortableRunDefinitionRegistryError as exc: raise PortableSetupProjectionError("portable setup is unavailable") from exc presentation = _presentation(definition) - compatibility = self._probe_source(definition, source.session_id, presentation) + compatibility = self._probe_source(definition, source, presentation) executor = definition.executor submission_allowed = ( compatibility.compatible and executor.ready and self._dispatch_available @@ -204,9 +209,51 @@ class PortableSetupProjector: def _probe_source( self, definition: PortableRunDefinition, - source_session_id: str, + source: SessionSummary, presentation: dict[str, str], ) -> _SourceCompatibility: + source_session_id = source.session_id + if self._equipment_capture_registry is not None: + expected_profile = ( + self._equipment_capture_registry.compatible_profile_for_requirements( + definition.source_requirements.as_dict() + ) + ) + if expected_profile is None: + raise PortableSetupProjectionError( + "portable source requirements have no capture profile allowlist" + ) + attestation = source.capture_attestation + if attestation is None: + return _SourceCompatibility( + compatible=False, + capability=None, + reason_code="capture-attestation-missing", + reason="У записи нет проверенной привязки к оборудованию и профилю записи.", + ) + if ( + attestation.equipment_model_id + != expected_profile.equipment.equipment_model_id + or attestation.equipment_model_sha256 + != expected_profile.equipment.equipment_model_sha256 + ): + return _SourceCompatibility( + compatible=False, + capability=None, + reason_code="equipment-model-mismatch", + reason="Модель оборудования записи не поддерживается этим профилем.", + ) + if ( + attestation.capture_profile_id != expected_profile.capture_profile_id + or attestation.capture_profile_sha256 + != expected_profile.capture_profile_sha256 + ): + return _SourceCompatibility( + compatible=False, + capability=None, + reason_code="capture-profile-not-allowed", + reason="Профиль записи не входит в точный список совместимости.", + ) try: capability = self._capability_probe.probe( source_session_id=source_session_id, @@ -217,6 +264,7 @@ class PortableSetupProjector: return _SourceCompatibility( compatible=False, capability=None, + reason_code="source-contract-mismatch", reason=presentation["incompatible"], ) if not isinstance(capability, PortableRecordedSourceCapability): @@ -230,6 +278,7 @@ class PortableSetupProjector: return _SourceCompatibility( compatible=True, capability=capability, + reason_code="source-compatible", reason=presentation["compatible"], ) diff --git a/src/k1link/observatory/source_admission.py b/src/k1link/observatory/source_admission.py index e98ab09..6fb8261 100644 --- a/src/k1link/observatory/source_admission.py +++ b/src/k1link/observatory/source_admission.py @@ -76,8 +76,8 @@ class PortableSourceAdmissionStaleError(PortableSourceAdmissionIntegrityError): @dataclass(frozen=True, slots=True) -class RecordedK1SourceRequirements: - """Typed matcher owned by a portable RunDefinition. +class RecordedSourceRequirements: + """Typed matcher owned by a portable recorded-source RunDefinition. ``camera_init_sha256`` is the exact ISO-BMFF initialization segment for the admitted codec/resolution profile. Width and height are asserted by that @@ -173,6 +173,11 @@ class RecordedK1SourceRequirements: return _sha256(self.adapter_document()) +# Compatibility import for already promoted packages. New code uses the +# equipment-neutral name; the serialized contract is unchanged. +RecordedK1SourceRequirements = RecordedSourceRequirements + + @dataclass(frozen=True, slots=True) class PortableRecordedSourceCapability: """Cheap catalog/summary attestation used by compatibility surfaces. diff --git a/src/k1link/sessions/equipment.py b/src/k1link/sessions/equipment.py new file mode 100644 index 0000000..c1e5670 --- /dev/null +++ b/src/k1link/sessions/equipment.py @@ -0,0 +1,380 @@ +"""Canonical equipment/capture identities for recorded observation sessions. + +The registry is intentionally small and content addressed. It answers only +which equipment contract produced a recording; it does not contain Worker, +model, queue, or command authority. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal + +from .models import ObservationSessionCandidate + +EQUIPMENT_REGISTRY_SCHEMA: Final = "missioncore.equipment-model-registry/v1" +EQUIPMENT_MODEL_SCHEMA: Final = "missioncore.equipment-model/v1" +CAPTURE_PROFILE_REGISTRY_SCHEMA: Final = ( + "missioncore.recorded-capture-profile-registry/v1" +) +CAPTURE_PROFILE_SCHEMA: Final = "missioncore.recorded-capture-profile/v1" +CAPTURE_ATTESTATION_SCHEMA: Final = "missioncore.recorded-capture-attestation/v1" + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_MODALITIES: Final = frozenset({"point-cloud", "trajectory", "video"}) +CaptureAttestationState = Literal[ + "attested", + "attested-with-legacy-evidence", + "unknown", +] + + +class EquipmentRegistryError(ValueError): + """A canonical equipment or capture registry is invalid.""" + + +def canonical_sha256(document: Mapping[str, object]) -> str: + payload = json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +@dataclass(frozen=True, slots=True) +class EquipmentModel: + equipment_model_id: str + equipment_model_sha256: str + document: Mapping[str, object] + + @property + def display_name(self) -> str: + return str(self.document["display_name"]) + + +@dataclass(frozen=True, slots=True) +class RecordedCaptureProfile: + capture_profile_id: str + capture_profile_sha256: str + equipment: EquipmentModel + document: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class RecordedCaptureAttestation: + equipment_model_id: str + equipment_model_sha256: str + equipment_display_name: str + capture_profile_id: str + capture_profile_sha256: str + capture_attestation_sha256: str + state: CaptureAttestationState + + def as_dict(self) -> dict[str, object]: + return { + "schema_version": CAPTURE_ATTESTATION_SCHEMA, + "equipment_model": { + "equipment_model_id": self.equipment_model_id, + "equipment_model_sha256": self.equipment_model_sha256, + "display_name": self.equipment_display_name, + }, + "capture_profile": { + "capture_profile_id": self.capture_profile_id, + "capture_profile_sha256": self.capture_profile_sha256, + }, + "capture_attestation_sha256": self.capture_attestation_sha256, + "state": self.state, + } + + +class EquipmentCaptureRegistry: + """Exact-match registry used by the host session catalog.""" + + def __init__( + self, + *, + equipment_models: tuple[EquipmentModel, ...], + capture_profiles: tuple[RecordedCaptureProfile, ...], + ) -> None: + self.equipment_models = equipment_models + self.capture_profiles = capture_profiles + self._profiles_by_origin = { + ( + str(profile.document["plugin_id"]), + str(profile.document["archive_id"]), + ): profile + for profile in capture_profiles + } + if len(self._profiles_by_origin) != len(capture_profiles): + raise EquipmentRegistryError("capture profile origins must be unique") + + @classmethod + def from_repository(cls, repository_root: Path) -> EquipmentCaptureRegistry | None: + equipment_path = repository_root / "config" / "observatory-equipment-models.json" + capture_path = ( + repository_root / "config" / "observatory-recorded-capture-profiles.json" + ) + if not equipment_path.exists() and not capture_path.exists(): + return None + if not equipment_path.is_file() or not capture_path.is_file(): + raise EquipmentRegistryError("equipment/capture registries must be installed together") + return cls.load(equipment_path=equipment_path, capture_path=capture_path) + + @classmethod + def load( + cls, + *, + equipment_path: Path, + capture_path: Path, + ) -> EquipmentCaptureRegistry: + equipment_payload = _read_json(equipment_path) + capture_payload = _read_json(capture_path) + if equipment_payload.get("schema_version") != EQUIPMENT_REGISTRY_SCHEMA: + raise EquipmentRegistryError("equipment registry schema is unsupported") + if capture_payload.get("schema_version") != CAPTURE_PROFILE_REGISTRY_SCHEMA: + raise EquipmentRegistryError("capture profile registry schema is unsupported") + equipment_rows = equipment_payload.get("models") + capture_rows = capture_payload.get("profiles") + if not isinstance(equipment_rows, list) or not equipment_rows: + raise EquipmentRegistryError("equipment registry must contain models") + if not isinstance(capture_rows, list) or not capture_rows: + raise EquipmentRegistryError("capture registry must contain profiles") + equipment_models = tuple(_equipment_model(row) for row in equipment_rows) + by_id = {item.equipment_model_id: item for item in equipment_models} + if len(by_id) != len(equipment_models): + raise EquipmentRegistryError("equipment model ids must be unique") + capture_profiles = tuple(_capture_profile(row, by_id) for row in capture_rows) + if len({item.capture_profile_id for item in capture_profiles}) != len( + capture_profiles + ): + raise EquipmentRegistryError("capture profile ids must be unique") + return cls( + equipment_models=equipment_models, + capture_profiles=capture_profiles, + ) + + def attest( + self, + *, + session_id: str, + plugin_id: str, + archive_id: str, + candidate: ObservationSessionCandidate, + ) -> RecordedCaptureAttestation | None: + profile = self._profiles_by_origin.get((plugin_id, archive_id)) + if profile is None: + return None + modalities_value = profile.document["modalities"] + channels_value = profile.document["semantic_channels"] + assert isinstance(modalities_value, list) + assert isinstance(channels_value, list) + required_modalities = tuple(str(item) for item in modalities_value) + required_channels = {str(item) for item in channels_value} + actual_channels = {source.semantic_channel_id for source in candidate.sources} + camera = profile.document["camera_media"] + assert isinstance(camera, dict) + expected_camera_source = camera["source_id"] + camera_sources = { + source.source_id + for source in candidate.sources + if source.modality == "video" and source.seekable + } + if ( + not set(required_modalities).issubset(candidate.modalities) + or not required_channels.issubset(actual_channels) + or expected_camera_source not in camera_sources + or not candidate.replayable + ): + return None + attestation_document: dict[str, object] = { + "schema_version": CAPTURE_ATTESTATION_SCHEMA, + "session_id": session_id, + "plugin_id": plugin_id, + "archive_id": archive_id, + "equipment_model_id": profile.equipment.equipment_model_id, + "equipment_model_sha256": profile.equipment.equipment_model_sha256, + "capture_profile_id": profile.capture_profile_id, + "capture_profile_sha256": profile.capture_profile_sha256, + "observed_modalities": sorted(candidate.modalities), + "observed_semantic_channels": sorted(actual_channels), + "state": "attested-with-legacy-evidence", + } + return RecordedCaptureAttestation( + equipment_model_id=profile.equipment.equipment_model_id, + equipment_model_sha256=profile.equipment.equipment_model_sha256, + equipment_display_name=profile.equipment.display_name, + capture_profile_id=profile.capture_profile_id, + capture_profile_sha256=profile.capture_profile_sha256, + capture_attestation_sha256=canonical_sha256(attestation_document), + state="attested-with-legacy-evidence", + ) + + def compatible_profile_for_requirements( + self, + requirements: Mapping[str, object], + ) -> RecordedCaptureProfile | None: + """Resolve an exact capture allowlist entry without changing RunDefinition bytes.""" + + for profile in self.capture_profiles: + row = profile.document + camera = row["camera_media"] + calibration = row["calibration"] + semantic_channels = row["semantic_channels"] + assert isinstance(camera, dict) + assert isinstance(calibration, dict) + assert isinstance(semantic_channels, list) + if ( + row["plugin_id"] == requirements.get("plugin_id") + and row["archive_id"] == requirements.get("archive_id") + and row["modalities"] == requirements.get("required_modalities") + and camera["source_id"] == requirements.get("camera_source_id") + and "camera.video.recorded" in semantic_channels + and requirements.get("camera_semantic_channel_id") + == "camera.video.recorded" + and camera["media_type"] == requirements.get("recorded_media_type") + and camera["initialization_sha256"] + == requirements.get("recorded_media_init_sha256") + and camera["width"] == requirements.get("camera_width") + and camera["height"] == requirements.get("camera_height") + and calibration["slot_id"] == requirements.get("calibration_slot") + and calibration["sha256"] + == requirements.get("calibration_identity_sha256") + and requirements.get("exactly_one_media_epoch") is True + and requirements.get("seekable") is True + ): + return profile + return None + + +def _read_json(path: Path) -> dict[str, object]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise EquipmentRegistryError("equipment/capture registry is unreadable") from exc + if not isinstance(payload, dict): + raise EquipmentRegistryError("equipment/capture registry must be an object") + return payload + + +def _equipment_model(value: object) -> EquipmentModel: + if not isinstance(value, dict) or value.get("schema_version") != EQUIPMENT_MODEL_SCHEMA: + raise EquipmentRegistryError("equipment model schema is unsupported") + equipment_model_id = _identifier(value.get("equipment_model_id"), "equipment model id") + plugin_id = _identifier(value.get("plugin_id"), "equipment plugin id") + if not plugin_id.startswith("nodedc.device."): + raise EquipmentRegistryError("equipment plugin id is not canonical") + version = value.get("equipment_model_version") + if isinstance(version, bool) or not isinstance(version, int) or version < 1: + raise EquipmentRegistryError("equipment model version is invalid") + capabilities = value.get("capability_ids") + if ( + not isinstance(capabilities, list) + or not capabilities + or capabilities != sorted(capabilities) + or len(set(capabilities)) != len(capabilities) + or any( + not isinstance(item, str) or _IDENTIFIER.fullmatch(item) is None + for item in capabilities + ) + ): + raise EquipmentRegistryError("equipment capabilities are invalid") + authority = value.get("authority") + if authority != {"commands_enabled": False}: + raise EquipmentRegistryError("equipment registry must be observation-only") + for field in ("vendor", "display_name", "category"): + if not isinstance(value.get(field), str) or not str(value[field]).strip(): + raise EquipmentRegistryError(f"equipment {field} is invalid") + return EquipmentModel( + equipment_model_id=equipment_model_id, + equipment_model_sha256=canonical_sha256(value), + document=value, + ) + + +def _capture_profile( + value: object, + equipment_models: Mapping[str, EquipmentModel], +) -> RecordedCaptureProfile: + if not isinstance(value, dict) or value.get("schema_version") != CAPTURE_PROFILE_SCHEMA: + raise EquipmentRegistryError("capture profile schema is unsupported") + profile_id = _identifier(value.get("capture_profile_id"), "capture profile id") + version = value.get("capture_profile_version") + if isinstance(version, bool) or not isinstance(version, int) or version < 1: + raise EquipmentRegistryError("capture profile version is invalid") + equipment_id = _identifier(value.get("equipment_model_id"), "capture equipment id") + equipment = equipment_models.get(equipment_id) + if equipment is None: + raise EquipmentRegistryError("capture profile references unknown equipment") + equipment_sha256 = value.get("equipment_model_sha256") + if equipment_sha256 != equipment.equipment_model_sha256: + raise EquipmentRegistryError("capture profile equipment digest does not match") + _identifier(value.get("plugin_id"), "capture plugin id") + _identifier(value.get("archive_id"), "capture archive id") + _identifier( + value.get("firmware_compatibility_profile_id"), + "firmware compatibility profile id", + ) + modalities = value.get("modalities") + if ( + not isinstance(modalities, list) + or not modalities + or modalities != sorted(modalities) + or not set(modalities).issubset(_MODALITIES) + ): + raise EquipmentRegistryError("capture modalities are invalid") + channels = value.get("semantic_channels") + if ( + not isinstance(channels, list) + or not channels + or channels != sorted(channels) + or any( + not isinstance(item, str) or _IDENTIFIER.fullmatch(item) is None + for item in channels + ) + ): + raise EquipmentRegistryError("capture semantic channels are invalid") + camera = value.get("camera_media") + calibration = value.get("calibration") + adapter = value.get("adapter") + if ( + not isinstance(camera, dict) + or not isinstance(calibration, dict) + or not isinstance(adapter, dict) + ): + raise EquipmentRegistryError("capture nested contracts are invalid") + _identifier(camera.get("source_id"), "capture camera source") + _digest(camera.get("initialization_sha256"), "capture camera initialization") + _identifier(calibration.get("slot_id"), "capture calibration slot") + _digest(calibration.get("sha256"), "capture calibration") + _identifier(adapter.get("adapter_id"), "capture adapter id") + _digest(adapter.get("adapter_sha256"), "capture adapter") + if value.get("media_epoch_policy") != "exactly-one-complete-epoch": + raise EquipmentRegistryError("capture media epoch policy is unsupported") + if value.get("seekable") is not True: + raise EquipmentRegistryError("capture profile must be seekable") + return RecordedCaptureProfile( + capture_profile_id=profile_id, + capture_profile_sha256=canonical_sha256(value), + equipment=equipment, + document=value, + ) + + +def _identifier(value: object, label: str) -> str: + if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None: + raise EquipmentRegistryError(f"{label} is invalid") + return value + + +def _digest(value: object, label: str) -> str: + if not isinstance(value, str) or _SHA256.fullmatch(value) is None: + raise EquipmentRegistryError(f"{label} digest is invalid") + return value diff --git a/src/k1link/sessions/models.py b/src/k1link/sessions/models.py index 4329d23..4273cb5 100644 --- a/src/k1link/sessions/models.py +++ b/src/k1link/sessions/models.py @@ -2,11 +2,17 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from .equipment import RecordedCaptureAttestation SessionStatus = Literal["ready", "interrupted", "failed"] SessionModality = Literal["point-cloud", "trajectory", "video"] LAB_REPLAY_CAPABILITY_SCHEMA = "missioncore.observation-lab-replay-capability/v1" +PORTABLE_LAB_REPLAY_CAPABILITY_SCHEMA = ( + "missioncore.observation-lab-replay-capability/v2" +) class SessionStoreError(RuntimeError): @@ -33,28 +39,41 @@ class LayoutConflictError(SessionStoreError): class LabReplayCapability: """Explicit, observation-only admission for a derived recorded replay.""" - schema_version: Literal["missioncore.observation-lab-replay-capability/v1"] - kind: Literal["canonical-recorded-rerun"] - viewer_profile: Literal["recorded-session"] - timeline: Literal["session_time"] + schema_version: Literal[ + "missioncore.observation-lab-replay-capability/v1", + "missioncore.observation-lab-replay-capability/v2", + ] + kind: Literal["canonical-recorded-rerun", "portable-result-review"] + viewer_profile: Literal["recorded-session", "portable-result"] + timeline: Literal["session_time", "result-defined"] activation: Literal["explicit"] commands_enabled: Literal[False] def __post_init__(self) -> None: - expected_text = ( - LAB_REPLAY_CAPABILITY_SCHEMA, - "canonical-recorded-rerun", - "recorded-session", - "session_time", - "explicit", - ) - if ( + identity = ( self.schema_version, self.kind, self.viewer_profile, self.timeline, self.activation, - ) != expected_text or self.commands_enabled is not False: + ) + admitted = { + ( + LAB_REPLAY_CAPABILITY_SCHEMA, + "canonical-recorded-rerun", + "recorded-session", + "session_time", + "explicit", + ), + ( + PORTABLE_LAB_REPLAY_CAPABILITY_SCHEMA, + "portable-result-review", + "portable-result", + "result-defined", + "explicit", + ), + } + if identity not in admitted or self.commands_enabled is not False: raise ValueError("LAB replay capability is invalid") def as_dict(self) -> dict[str, object]: @@ -160,6 +179,7 @@ class SessionSummary: total_bytes: int replayable: bool origin: str + capture_attestation: RecordedCaptureAttestation | None = None lab: LabSessionBinding | None = None def as_dict(self) -> dict[str, Any]: @@ -176,6 +196,11 @@ class SessionSummary: "total_bytes": self.total_bytes, "replayable": self.replayable, "origin": self.origin, + "capture_attestation": ( + None + if self.capture_attestation is None + else self.capture_attestation.as_dict() + ), } if self.lab is not None: document["lab"] = self.lab.as_dict() diff --git a/src/k1link/sessions/store.py b/src/k1link/sessions/store.py index ddfac7a..8c1367f 100644 --- a/src/k1link/sessions/store.py +++ b/src/k1link/sessions/store.py @@ -16,7 +16,15 @@ from typing import Any, Literal, cast from uuid import uuid4 from k1link.artifacts import utc_now_iso +from k1link.laboratory.canonical_recorded_migration import ( + matches_historical_recorded_projection, +) +from .equipment import ( + CaptureAttestationState, + EquipmentCaptureRegistry, + RecordedCaptureAttestation, +) from .models import ( LabReplayCapability, LabSessionBinding, @@ -72,6 +80,16 @@ CREATE TABLE IF NOT EXISTS observation_sessions ( session_root TEXT NOT NULL, created_at_utc TEXT NOT NULL, updated_at_utc TEXT NOT NULL + ,equipment_model_id TEXT + ,equipment_model_sha256 TEXT + ,equipment_display_name TEXT + ,capture_profile_id TEXT + ,capture_profile_sha256 TEXT + ,capture_attestation_sha256 TEXT + ,capture_attestation_state TEXT + CHECK (capture_attestation_state IN ( + 'attested', 'attested-with-legacy-evidence', 'unknown' + )) ); CREATE INDEX IF NOT EXISTS observation_sessions_recent @@ -167,8 +185,15 @@ class SessionStore: self.data_dir.chmod(0o700) self.database_path = self.data_dir / DATABASE_NAME self._lock = threading.RLock() + self._equipment_registry = EquipmentCaptureRegistry.from_repository( + self.repository_root + ) self._initialize() + @property + def equipment_capture_registry(self) -> EquipmentCaptureRegistry | None: + return self._equipment_registry + def reconcile_archive(self, source: ObservationArchiveSource) -> tuple[str, ...]: """Reconcile one plugin-owned evidence namespace into the host catalog.""" @@ -538,6 +563,9 @@ class SessionStore: else duration_seconds ), include_recorded_media=include_recorded_media, + require_recorded_replay=( + replay_capability.viewer_profile == "recorded-session" + ), ) if stored_recorded_media_policy is None: connection.execute( @@ -565,8 +593,12 @@ class SessionStore: "replayable, origin, source_count, total_bytes, " "primary_replay_artifact_id, timeline_origin_epoch_ns, " "timeline_origin_monotonic_ns, allowed_root, session_root, " - "created_at_utc, updated_at_utc) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "created_at_utc, updated_at_utc, equipment_model_id, " + "equipment_model_sha256, equipment_display_name, capture_profile_id, " + "capture_profile_sha256, capture_attestation_sha256, " + "capture_attestation_state) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + "?, ?, ?, ?, ?, ?, ?)", ( session_id, source["plugin_id"], @@ -592,6 +624,13 @@ class SessionStore: source["session_root"], published_at, published_at, + source["equipment_model_id"], + source["equipment_model_sha256"], + source["equipment_display_name"], + source["capture_profile_id"], + source["capture_profile_sha256"], + source["capture_attestation_sha256"], + source["capture_attestation_state"], ), ) connection.execute( @@ -972,6 +1011,17 @@ class SessionStore: ("primary_replay_artifact_id", "TEXT"), ("timeline_origin_epoch_ns", "INTEGER"), ("timeline_origin_monotonic_ns", "INTEGER"), + ("equipment_model_id", "TEXT"), + ("equipment_model_sha256", "TEXT"), + ("equipment_display_name", "TEXT"), + ("capture_profile_id", "TEXT"), + ("capture_profile_sha256", "TEXT"), + ("capture_attestation_sha256", "TEXT"), + ( + "capture_attestation_state", + "TEXT CHECK (capture_attestation_state IN (" + "'attested', 'attested-with-legacy-evidence', 'unknown'))", + ), ): if name in session_columns: continue @@ -1054,6 +1104,16 @@ class SessionStore: _validate_candidate_replay(candidate, artifacts) now = utc_now_iso() modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":")) + capture_attestation = ( + None + if self._equipment_registry is None + else self._equipment_registry.attest( + session_id=candidate.session_id, + plugin_id=source.plugin_id, + archive_id=source.archive_id, + candidate=candidate, + ) + ) with self._lock, self._connect() as connection: connection.execute("BEGIN IMMEDIATE") @@ -1086,8 +1146,12 @@ class SessionStore: "duration_seconds, modalities_json, replayable, origin, source_count, " "total_bytes, primary_replay_artifact_id, timeline_origin_epoch_ns, " "timeline_origin_monotonic_ns, allowed_root, " - "session_root, created_at_utc, updated_at_utc) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "session_root, created_at_utc, updated_at_utc, equipment_model_id, " + "equipment_model_sha256, equipment_display_name, capture_profile_id, " + "capture_profile_sha256, capture_attestation_sha256, " + "capture_attestation_state) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + "?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(session_id) DO UPDATE SET " "plugin_id = excluded.plugin_id, archive_id = excluded.archive_id, " "display_name = excluded.display_name, status = excluded.status, " @@ -1100,6 +1164,13 @@ class SessionStore: "primary_replay_artifact_id = excluded.primary_replay_artifact_id, " "timeline_origin_epoch_ns = excluded.timeline_origin_epoch_ns, " "timeline_origin_monotonic_ns = excluded.timeline_origin_monotonic_ns, " + "equipment_model_id = excluded.equipment_model_id, " + "equipment_model_sha256 = excluded.equipment_model_sha256, " + "equipment_display_name = excluded.equipment_display_name, " + "capture_profile_id = excluded.capture_profile_id, " + "capture_profile_sha256 = excluded.capture_profile_sha256, " + "capture_attestation_sha256 = excluded.capture_attestation_sha256, " + "capture_attestation_state = excluded.capture_attestation_state, " "updated_at_utc = excluded.updated_at_utc", ( candidate.session_id, @@ -1122,6 +1193,37 @@ class SessionStore: str(session_root), created_at, now, + ( + None + if capture_attestation is None + else capture_attestation.equipment_model_id + ), + ( + None + if capture_attestation is None + else capture_attestation.equipment_model_sha256 + ), + ( + None + if capture_attestation is None + else capture_attestation.equipment_display_name + ), + ( + None + if capture_attestation is None + else capture_attestation.capture_profile_id + ), + ( + None + if capture_attestation is None + else capture_attestation.capture_profile_sha256 + ), + ( + None + if capture_attestation is None + else capture_attestation.capture_attestation_sha256 + ), + None if capture_attestation is None else capture_attestation.state, ), ) connection.execute( @@ -1246,6 +1348,7 @@ def _validate_existing_lab_projection( run_created_at_utc: str, duration_seconds: float | None, include_recorded_media: bool, + require_recorded_replay: bool = True, ) -> None: """Validate the immutable companion snapshot without rereading a mutable source row.""" @@ -1282,7 +1385,7 @@ def _validate_existing_lab_projection( raise SessionIntegrityError( "LAB session id is already bound to different catalog metadata" ) - if ( + if require_recorded_replay and ( summary["status"] != "ready" or summary["replayable"] != 1 or summary["primary_replay_artifact_id"] is None @@ -1546,16 +1649,12 @@ def _migrate_canonical_replay_capabilities(connection: sqlite3.Connection) -> No "SELECT * FROM observation_sessions WHERE session_id = ?", (row["source_session_id"],), ).fetchone() - if ( - summary["display_name"] - != "RAVNOVES004TREE · полный маршрут восприятия" - or summary["duration_seconds"] != 718.0 - or row["run_created_at_utc"] - != "2026-08-29T18:05:11.329061+00:00" - or source_summary is None - or source_summary["plugin_id"] - != "nodedc.device.xgrids-lixelkity-k1" - or source_summary["archive_id"] != "xgrids-k1.viewer-live.evidence" + if source_summary is None or not matches_historical_recorded_projection( + display_name=summary["display_name"], + duration_seconds=summary["duration_seconds"], + run_created_at_utc=row["run_created_at_utc"], + source_plugin_id=source_summary["plugin_id"], + source_archive_id=source_summary["archive_id"], ): raise SessionIntegrityError( "canonical rolling LAB projection metadata is invalid" @@ -1725,6 +1824,9 @@ def _require_capability_owned_lab_projection( run_created_at_utc=binding.run_created_at_utc, duration_seconds=summary["duration_seconds"], include_recorded_media=bool(lab["include_recorded_media"]), + require_recorded_replay=( + binding.replay_capability.viewer_profile == "recorded-session" + ), ) return summary, lab @@ -1749,10 +1851,48 @@ def _summary_from_row( total_bytes=row["total_bytes"], replayable=bool(row["replayable"]), origin=row["origin"], + capture_attestation=_capture_attestation_from_row(row), lab=lab, ) +def _capture_attestation_from_row( + row: sqlite3.Row, +) -> RecordedCaptureAttestation | None: + values = ( + row["equipment_model_id"], + row["equipment_model_sha256"], + row["equipment_display_name"], + row["capture_profile_id"], + row["capture_profile_sha256"], + row["capture_attestation_sha256"], + row["capture_attestation_state"], + ) + if all(value is None for value in values): + return None + if any(not isinstance(value, str) or not value for value in values): + raise SessionIntegrityError("stored capture attestation is incomplete") + state = row["capture_attestation_state"] + if state not in {"attested", "attested-with-legacy-evidence", "unknown"}: + raise SessionIntegrityError("stored capture attestation state is invalid") + for digest in ( + row["equipment_model_sha256"], + row["capture_profile_sha256"], + row["capture_attestation_sha256"], + ): + if SHA256_PATTERN.fullmatch(digest) is None: + raise SessionIntegrityError("stored capture attestation digest is invalid") + return RecordedCaptureAttestation( + equipment_model_id=row["equipment_model_id"], + equipment_model_sha256=row["equipment_model_sha256"], + equipment_display_name=row["equipment_display_name"], + capture_profile_id=row["capture_profile_id"], + capture_profile_sha256=row["capture_profile_sha256"], + capture_attestation_sha256=row["capture_attestation_sha256"], + state=cast(CaptureAttestationState, state), + ) + + def _lab_binding_from_row(row: sqlite3.Row) -> LabSessionBinding: try: provenance = json.loads(row["provenance_json"]) diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index 7d8d48c..0c2dc02 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -383,6 +383,11 @@ def build_session_router( "modalities": list(item.modalities), "duration_seconds": item.duration_seconds or 0.0, "replayable": item.replayable, + "capture_attestation": ( + None + if item.capture_attestation is None + else item.capture_attestation.as_dict() + ), **( { "lab": lab_catalog_document(item, lab_contract) diff --git a/tests/test_observatory_equipment_registry.py b/tests/test_observatory_equipment_registry.py new file mode 100644 index 0000000..3a93bdc --- /dev/null +++ b/tests/test_observatory_equipment_registry.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from pathlib import Path + +from k1link.observatory.portable_run_definitions import PortableRunDefinitionRegistry +from k1link.observatory.portable_setup_projection import PortableSetupProjector +from k1link.sessions.equipment import ( + EquipmentCaptureRegistry, + RecordedCaptureAttestation, +) +from k1link.sessions.models import ( + ObservationArtifactCandidate, + ObservationSessionCandidate, + SessionSource, + SessionSummary, +) +from k1link.sessions.plugin_contract import ObservationArchiveSource +from k1link.sessions.store import SessionStore + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def _equipment_registry() -> EquipmentCaptureRegistry: + registry = EquipmentCaptureRegistry.from_repository(REPOSITORY_ROOT) + assert registry is not None + return registry + + +def _definition_registry() -> PortableRunDefinitionRegistry: + return PortableRunDefinitionRegistry.from_file( + REPOSITORY_ROOT / "config" / "observatory-portable-run-definitions.json" + ) + + +def test_k1_equipment_and_capture_profiles_have_exact_content_identities() -> None: + registry = _equipment_registry() + assert len(registry.equipment_models) == 1 + assert len(registry.capture_profiles) == 1 + equipment = registry.equipment_models[0] + capture = registry.capture_profiles[0] + + assert equipment.equipment_model_id == "xgrids.lixelkity-k1" + assert equipment.equipment_model_sha256 == ( + "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa" + ) + assert capture.equipment == equipment + for definition in _definition_registry().definitions: + assert ( + registry.compatible_profile_for_requirements( + definition.source_requirements.as_dict() + ) + == capture + ) + + +def test_session_store_persists_immutable_k1_capture_attestation(tmp_path: Path) -> None: + archive_root = tmp_path / "evidence" + session_root = archive_root / "recording-001" + session_root.mkdir(parents=True) + raw = session_root / "capture.bin" + video = session_root / "camera.mp4" + raw.write_bytes(b"sealed spatial capture") + video.write_bytes(b"sealed camera capture") + candidate = _k1_candidate(archive_root, session_root, raw, video) + source = ObservationArchiveSource( + plugin_id="nodedc.device.xgrids-lixelkity-k1", + archive_id="xgrids-k1.viewer-live.evidence", + root=archive_root, + discover=lambda _root: (candidate,), + ) + data_dir = tmp_path / "data" + store = SessionStore(REPOSITORY_ROOT, data_dir=data_dir) + + assert store.reconcile_archive(source) == ("recording-001",) + attestation = store.get_session("recording-001").summary.capture_attestation + assert attestation is not None + assert attestation.equipment_model_id == "xgrids.lixelkity-k1" + assert attestation.capture_profile_id == "xgrids-k1.viewer-live.fw-3.0.2.v1" + assert attestation.state == "attested-with-legacy-evidence" + + reopened = SessionStore(REPOSITORY_ROOT, data_dir=data_dir) + assert reopened.get_session("recording-001").summary.capture_attestation == attestation + + +def test_other_equipment_is_blocked_before_any_profile_probe() -> None: + registry = _equipment_registry() + calls: list[str] = [] + + class Probe: + def probe(self, **kwargs: str) -> object: + calls.append(kwargs["source_session_id"]) + raise AssertionError("incompatible equipment must not reach source probing") + + projector = PortableSetupProjector( + registry=_definition_registry(), + capability_probe=Probe(), + dispatch_available=True, + equipment_capture_registry=registry, + ) + source = _summary( + RecordedCaptureAttestation( + equipment_model_id="example.other-scanner", + equipment_model_sha256="1" * 64, + equipment_display_name="Other scanner", + capture_profile_id="example.other.capture.v1", + capture_profile_sha256="2" * 64, + capture_attestation_sha256="3" * 64, + state="attested", + ) + ) + + catalog = projector.catalog(source) + assert calls == [] + assert len(catalog["setups"]) == 2 + for setup in catalog["setups"]: + assert setup["source_compatibility"]["compatible"] is False + assert setup["source_compatibility"]["reason_code"] == ( + "equipment-model-mismatch" + ) + assert setup["preflight"]["submission_allowed"] is False + + +def _summary(attestation: RecordedCaptureAttestation) -> SessionSummary: + return SessionSummary( + session_id="other-equipment-recording-001", + display_name="Other equipment", + status="ready", + started_at_utc="2026-09-01T10:00:00Z", + completed_at_utc="2026-09-01T10:01:00Z", + duration_seconds=60.0, + modalities=("point-cloud", "trajectory", "video"), + source_count=3, + total_bytes=1, + replayable=True, + origin="example.other.archive", + capture_attestation=attestation, + ) + + +def _k1_candidate( + archive_root: Path, + session_root: Path, + raw: Path, + video: Path, +) -> ObservationSessionCandidate: + return ObservationSessionCandidate( + session_id="recording-001", + display_name="K1 recording", + status="ready", + started_at_utc="2026-09-01T10:00:00Z", + completed_at_utc="2026-09-01T10:01:00Z", + duration_seconds=60.0, + modalities=("point-cloud", "trajectory", "video"), + replayable=True, + total_bytes=raw.stat().st_size + video.stat().st_size, + allowed_root=archive_root, + session_root=session_root, + primary_replay_artifact_id="raw-transport-primary", + timeline_origin_epoch_ns=1, + timeline_origin_monotonic_ns=1, + sources=( + SessionSource( + source_id="sensor.lidar.primary", + semantic_channel_id="spatial.point-cloud.recorded", + modality="point-cloud", + status="recorded", + seekable=True, + artifact_id="raw-transport-primary", + ), + SessionSource( + source_id="spatial.trajectory", + semantic_channel_id="spatial.pose.recorded", + modality="trajectory", + status="recorded", + seekable=True, + artifact_id="raw-transport-primary", + ), + SessionSource( + source_id="sensor.camera.right", + semantic_channel_id="camera.video.recorded", + modality="video", + status="recorded", + seekable=True, + artifact_id="camera-right", + ), + ), + artifacts=( + ObservationArtifactCandidate( + artifact_id="raw-transport-primary", + kind="native-recording", + media_type="application/x-k1-recording", + locator=raw, + byte_length=raw.stat().st_size, + replay_byte_length=raw.stat().st_size, + sha256=None, + integrity_status="validated-structure", + ), + ObservationArtifactCandidate( + artifact_id="camera-right", + kind="recorded-video", + media_type="video/mp4", + locator=video, + byte_length=video.stat().st_size, + replay_byte_length=0, + sha256=None, + integrity_status="validated-structure", + ), + ), + ) diff --git a/tests/test_observatory_portable_setup_projection.py b/tests/test_observatory_portable_setup_projection.py index 0823d9c..ee326b6 100644 --- a/tests/test_observatory_portable_setup_projection.py +++ b/tests/test_observatory_portable_setup_projection.py @@ -174,22 +174,23 @@ def test_calculation_profile_policies_cover_both_exact_portable_definitions() -> assert resolved["m49-tgs-portable-v2"].display_name == PORTABLE_M49_DISPLAY_NAME -def test_new_compatible_source_passes_capability_but_uninstalled_executor_blocks() -> None: +def test_new_compatible_source_passes_capability_but_disabled_dispatch_blocks() -> None: source = _source(NEW_SESSION_ID) setup = _projector(lambda session_id: _capability(session_id)).project(source) assert setup["source_compatibility"] == { "outcome": "pass", "compatible": True, + "reason_code": "source-compatible", "reason": "Запись соответствует требованиям EoMT + DDRNet.", } - assert setup["executor"]["state"] == "not-installed" - assert setup["executor"]["ready"] is False + assert setup["executor"]["state"] == "ready" + assert setup["executor"]["ready"] is True assert setup["existing_results"] == [] assert setup["preflight"] == { "outcome": "blocked", "action": "blocked", - "reason": "Вычислительный контур LAB V1 пока недоступен.", + "reason": "Server-side проверка и постановка portable-сетапа в очередь недоступны.", "submission_allowed": False, "existing_result_ids": [], } @@ -207,7 +208,7 @@ def test_legacy_vegetation_result_is_never_existing_for_portable_v2() -> None: assert setup["preflight"] == { "outcome": "blocked", "action": "blocked", - "reason": "Вычислительный контур LAB V1 пока недоступен.", + "reason": "Server-side проверка и постановка portable-сетапа в очередь недоступны.", "submission_allowed": False, "existing_result_ids": [], } @@ -225,6 +226,7 @@ def test_real_capability_probe_rejection_wins_over_summary() -> None: assert setup["source_compatibility"] == { "outcome": "blocked", "compatible": False, + "reason_code": "source-contract-mismatch", "reason": "Запись не соответствует требованиям EoMT + DDRNet.", } assert setup["existing_results"] == [] diff --git a/tests/test_session_api.py b/tests/test_session_api.py index 4823f42..b506f2a 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -292,6 +292,7 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat "modalities": ["point-cloud", "trajectory"], "duration_seconds": 1440.1, "replayable": True, + "capture_attestation": None, } assert detail["timeline"]["seekable"] is True assert detail["modalities"] == ["point-cloud", "trajectory"]