diff --git a/docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md b/docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md new file mode 100644 index 0000000..12448e1 --- /dev/null +++ b/docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md @@ -0,0 +1,108 @@ +# Mission Core experimental vocabulary v0alpha2 + +- Status: experimental, local to Mission Core and its Plugin SDK +- Contract API: `missioncore.nodedc/plugin-sdk/v0alpha2` +- Scope: laboratory device integration; not yet a NODE.DC platform ontology + +This vocabulary fixes the meaning of the executable Plugin SDK contracts while +their domain model is still being discovered. It is intentionally not loaded by +Ontology Core. A term is promoted into the platform ontology only after its +meaning is stable outside one device plugin, its owner and lifecycle are known, +and migration from experimental captures is defined. + +## Identity and lifecycle + +| Term | Meaning | +|---|---| +| `DeviceModelRef` | A plugin-owned device model definition, not physical hardware. | +| `DeviceInstanceRef` | A particular physical or logically enrolled device. Its ID may be stable or explicitly provisional. | +| `TransportAlias` | An address observed in one scope, such as a BLE UUID assigned by one host or an IP address. It is not a stable identity. | +| `DeviceSessionContext` | One bounded period in which one plugin adapter on one execution node communicates with one device instance. | +| `ExecutionBinding` | The logical node and concrete agent process that owns a session. | +| `Acquisition` | One bounded production of sensor data. It may span multiple canonical channels. | +| `Operation` | One requested, auditable action with its own ID, deadline and terminal result. | + +Enrollment, connectivity, and acquisition are independent state machines. A +device can be enrolled while offline; it can be connected while acquisition is +idle; and a failed acquisition does not erase device identity. + +## Operations + +An acknowledgement records receipt or admission of a request. It never proves +that the requested physical action completed. Completion and failure are +separate terminal events. Calibration, acquisition start, and acquisition stop +therefore require an operation ID, explicit timeout, device/profile-specific +acknowledgement evidence, progress when available, and exactly one terminal +outcome. + +Secret material is not a contract value. Operations carry only +`SecretReference` objects; raw passwords, tokens, and credentials must not +enter parameters, events, logs, browser storage, manifests, fixtures, or +evidence metadata. + +## Data and evidence + +| Term | Meaning | +|---|---| +| `RawTransportRecord` | Immutable bytes and transport context observed before vendor decoding. | +| `CanonicalStreamEnvelope` | Vendor-neutral data produced by a decoder, with device/session/acquisition lineage. | +| `EvidenceHandle` | Content-addressed reference to immutable stored bytes. | +| `EvidenceRecord` | Metadata, retention, redaction state and derivation lineage for one handle. | +| `CompatibilityAssessment` | A point-in-time, evidence-backed decision about a device and compatibility profile. | + +Decoding creates derived evidence or a canonical stream; it never overwrites +the raw observation. A viewer is a consumer of canonical data and does not own +device protocol, transport, identity, or command state. + +## Mandatory non-conflation rules + +1. `DeviceModelRef` is not `DeviceInstanceRef`. +2. `DeviceInstanceRef` is not `DeviceSessionContext`. +3. A host-local BLE UUID, IP address, SSID, MQTT client ID, or USB path is a + `TransportAlias`, not a stable `device_id`. +4. Enrollment state is not connectivity state; connectivity state is not + acquisition state. +5. An operation acknowledgement is not operation completion. +6. A command published to a transport is not a successful physical action. +7. K1 internal sensor calibration is not sensor-to-vehicle extrinsic + calibration and is not geodetic calibration. +8. Raw evidence is not a decoded frame; a decoded frame is not viewer state. +9. Device time is not host time unless an explicit clock relationship has been + measured and recorded. +10. A camera preview is not assumed to be an independent raw camera stream. +11. Compatibility with one firmware/profile is not compatibility with a model + family or a future firmware. +12. A plugin UI contribution is not ownership of Mission Core navigation, + scene layout, persistence, or global application state. + +## Deferred compatibility work + +The following meanings are intentionally unresolved in v0alpha2. They must be +defined and covered by migration tests before the portable contracts replace +the transitional live path: + +1. Canonical clock representation must distinguish UTC instants from native + host/device monotonic ticks or nanoseconds and record the measured mapping + between them. An aware datetime alone is not a monotonic timestamp. +2. `ReferencedPayload` must define whether `byte_length` describes the complete + referenced object or the selected segment, and how `offset` and + `segment_length` participate in a frame's exact layout length. +3. The coarse SDK `AcquisitionState` values require an explicit mapping to the + richer transitional states such as `prepared`, `awaiting_external_start`, + `acquiring`, `awaiting_external_stop`, `finalizing`, and `completed`. +4. Generic manifest validation currently proves profile-link integrity, model + coverage, path confinement, and profile identity. Evidence flags and + device-specific semantics are validated by each plugin's fail-closed loader; + they are not yet a generic compatibility-profile schema. + +## Change and promotion policy + +- Additive experimental changes remain in v0alpha2 only when an older document + retains exactly the same meaning and remains valid. +- Breaking shape or semantic changes create a new versioned contract module. +- Deprecated fields and enum values remain readable until an explicit migration + exists; they are never silently renamed or reused. +- Raw captures retain their original contract/profile versions and hashes. +- Promotion to Ontology Core requires at least one additional device family or + a second platform service using the same meaning, an agreed owner, lifecycle, + compatibility policy, and migration tests. diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 5c4b391..66e8174 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -1,29 +1,53 @@ # Mission Core Plugin SDK -This directory owns the versioned host/plugin contract. The v1alpha frontend -contract, validated registry, generic runtime envelope, and backend read-only -catalog are implemented in-tree, but this is not yet a separately published -SDK package. +This directory owns the versioned host/plugin contract. It is an independently +installable in-tree package; it is not yet published to a package registry. -The v1alpha1 contract currently validates one model per plugin, one reviewed -`transitional-in-process` backend entrypoint, a required safe `state.read` -action, and declared UI/action metadata. It establishes: +`missioncore_plugin_sdk.v0alpha2` is the first executable backend-neutral +contract layer. It uses closed, deeply immutable Pydantic models and exports +JSON Schema for TypeScript, Rust, or other consumers. Nested mappings and +collections are frozen after validation, and `model_copy(update=...)` is +revalidated before a new contract is returned. Import the explicit version: -- plugin manifest, version compatibility, firmware profiles, and declarative - permissions; -- discovery candidates and opaque device references; -- provisioning requests using secret references; -- device-session lifecycle and health; -- EvidenceStore handles and raw-artifact lineage; -- canonical PointCloud, Pose, DeviceStatus, and metrics envelopes; -- SceneSink and event interfaces; -- capability-driven UI contribution data without plugin-owned layout. +```python +from missioncore_plugin_sdk import v0alpha2 as sdk -The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic -multi-plugin composition tests verify that backend routing has no K1 identity or -protocol assumption; static boundary tests enforce the same rule in the frontend. +request = sdk.OperationRequest.model_validate(document) +schemas = sdk.contract_json_schemas() +``` -Current implementation references: +For an isolated editable install: + +```bash +uv pip install -e packages/plugin-sdk +python -m missioncore_plugin_sdk.v0alpha2 > plugin-sdk-v0alpha2.schema-bundle.json +``` + +## Contract generations + +The host accepts the original one-model v1alpha1 manifest and the additive +v1alpha2 manifest. v1alpha2 permits one or more models, requires plugin-local +compatibility-profile coverage for every model, and retains one reviewed +`transitional-in-process` backend entrypoint plus a safe `state.read` action. +The separate SDK v0alpha2 package establishes executable contracts for: + +- stable and provisional device identities, transport aliases, and execution + node bindings; +- independently revisioned enrollment, connectivity, and acquisition states; +- operation policy, request, acknowledgement, progress, completion, failure, + timeout, cancellation, secret reference, and idempotency boundaries; +- canonical point cloud, pose, image, encoded video, and device-status streams; +- immutable evidence handles, raw transport records, lineage, and store + protocol; +- firmware/profile compatibility assessments that fail closed before active + device control; +- deterministic JSON Schema export for non-Python consumers. + +The contracts deliberately contain no MQTT topic, BLE UUID, protobuf type, +concrete viewer, or XGRIDS-specific field. Those belong to a plugin +compatibility profile and adapter. + +Current host implementation references: - `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI contribution contracts; @@ -31,13 +55,20 @@ Current implementation references: - `src/k1link/web/plugin_catalog.py` — strict backend manifest validation; - `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher; - `src/k1link/web/device_plugin_composition.py` — manifest factory loader and - startup parity check between catalog and executable adapters; + startup parity checks; - `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary and extraction sequence. -The v1alpha lifecycle is fail-closed: inactive provider shells must perform no +The existing lifecycle is fail-closed: inactive provider shells perform no I/O, events are scoped by plugin ID, and selection cannot change until the -active plugin confirms teardown. Process isolation and independent device -session IDs remain the next SDK milestone. `permissions`, `mutating`, and -`secretFields` are contract metadata only in v1alpha1; host authorization and -secret-vault enforcement are not implemented yet. +active plugin confirms teardown. Process isolation remains a later host +milestone. v0alpha2 models secret references and operation policy, but host +authorization and the actual secret vault remain separate implementation +responsibilities. + +## Version policy + +v0alpha2 may receive additive fields while it remains experimental. A breaking +wire or semantic change creates a new explicit module (for example +`v0alpha3`); recorded documents keep their original `api_version`. Published +field meanings and enum values are never silently redefined. diff --git a/packages/plugin-sdk/pyproject.toml b/packages/plugin-sdk/pyproject.toml new file mode 100644 index 0000000..64fa718 --- /dev/null +++ b/packages/plugin-sdk/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "missioncore-plugin-sdk" +version = "0.2.0a2" +description = "Versioned host/device-plugin contracts for NODE.DC Mission Core" +readme = "README.md" +requires-python = ">=3.12,<3.13" +license = { text = "Proprietary" } +dependencies = ["pydantic>=2.11,<3"] + +[tool.hatch.build.targets.wheel] +packages = ["python/missioncore_plugin_sdk"] diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/__init__.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/__init__.py new file mode 100644 index 0000000..a46fac6 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/__init__.py @@ -0,0 +1,11 @@ +"""Versioned Mission Core host/plugin contracts. + +The package is intentionally kept independent from the Mission Core runtime and +from every concrete device plugin. Consumers must import an explicit contract +version so an experimental contract cannot silently change underneath recorded +evidence or a deployed adapter. +""" + +from . import v0alpha2 + +__all__ = ["v0alpha2"] diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py new file mode 100644 index 0000000..61df6b4 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py @@ -0,0 +1,130 @@ +"""Mission Core Plugin SDK v0alpha2. + +This version is experimental and additive to v1alpha1. It defines the runtime +contract boundary needed to extract concrete device plugins without claiming a +stable platform ontology. +""" + +from .common import API_VERSION +from .compatibility import ( + CompatibilityAssessment, + CompatibilityDecision, + CompatibilityRuleResult, + PermittedMode, + RuleOutcome, +) +from .evidence import ( + EvidenceHandle, + EvidenceRecord, + EvidenceRetention, + EvidenceStore, + RawTransportRecord, + RedactionState, +) +from .identity import ( + DeviceInstanceRef, + DeviceModelRef, + ExecutionBinding, + ExecutionPlatform, + IdentityBasis, + IdentityStability, + TransportAlias, +) +from .operations import ( + AcknowledgementDisposition, + IdempotencyMode, + OperationAcknowledgement, + OperationCompletion, + OperationError, + OperationEvent, + OperationFailure, + OperationPolicy, + OperationProgress, + OperationRequest, + OperationSafetyClass, + OperationTerminalState, + SecretReference, + validate_operation_request, +) +from .payloads import InlinePayload, PayloadHandle, ReferencedPayload +from .schema import contract_json_schemas +from .session import ( + AcquisitionState, + ConnectivityState, + DeviceSessionContext, + DeviceSessionRef, + DeviceSessionSnapshot, + EnrollmentState, +) +from .streams import ( + CanonicalStreamEnvelope, + CanonicalStreamHeader, + ClockDomain, + DeviceStatusFrame, + EncodedVideoPacket, + ImageFrame, + PointCloudFrame, + PointField, + PointFieldType, + PoseFrame, + Quaternion, + Vector3, +) + +__all__ = [ + "API_VERSION", + "AcknowledgementDisposition", + "AcquisitionState", + "CanonicalStreamEnvelope", + "CanonicalStreamHeader", + "ClockDomain", + "CompatibilityAssessment", + "CompatibilityDecision", + "CompatibilityRuleResult", + "ConnectivityState", + "DeviceInstanceRef", + "DeviceModelRef", + "DeviceSessionContext", + "DeviceSessionRef", + "DeviceSessionSnapshot", + "DeviceStatusFrame", + "EncodedVideoPacket", + "EnrollmentState", + "EvidenceHandle", + "EvidenceRecord", + "EvidenceRetention", + "EvidenceStore", + "ExecutionBinding", + "ExecutionPlatform", + "IdentityBasis", + "IdentityStability", + "IdempotencyMode", + "ImageFrame", + "InlinePayload", + "OperationAcknowledgement", + "OperationCompletion", + "OperationError", + "OperationEvent", + "OperationFailure", + "OperationPolicy", + "OperationProgress", + "OperationRequest", + "OperationSafetyClass", + "OperationTerminalState", + "PayloadHandle", + "PermittedMode", + "PointCloudFrame", + "PointField", + "PointFieldType", + "PoseFrame", + "Quaternion", + "RawTransportRecord", + "RedactionState", + "ReferencedPayload", + "RuleOutcome", + "SecretReference", + "TransportAlias", + "Vector3", + "contract_json_schemas", + "validate_operation_request", +] diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__main__.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__main__.py new file mode 100644 index 0000000..e2fd684 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__main__.py @@ -0,0 +1,9 @@ +"""Print the v0alpha2 JSON Schema bundle.""" + +from __future__ import annotations + +import json + +from .schema import contract_json_schemas + +print(json.dumps(contract_json_schemas(), ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/common.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/common.py new file mode 100644 index 0000000..790b88f --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/common.py @@ -0,0 +1,131 @@ +"""Shared primitives for the Mission Core Plugin SDK v0alpha2 contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Annotated, Any, Literal, Never, Self + +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue, model_validator + +ApiVersion = Literal["missioncore.nodedc/plugin-sdk/v0alpha2"] +API_VERSION: ApiVersion = "missioncore.nodedc/plugin-sdk/v0alpha2" + + +def _reject_blank(value: str) -> str: + if not value.strip(): + raise ValueError("must contain a non-whitespace character") + return value + + +Identifier = Annotated[ + str, + Field(min_length=1, max_length=192, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"), + AfterValidator(_reject_blank), +] +ShortText = Annotated[ + str, + Field(min_length=1, max_length=256), + AfterValidator(_reject_blank), +] +LongText = Annotated[ + str, + Field(min_length=1, max_length=4096), + AfterValidator(_reject_blank), +] +Sha256Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] +JsonObject = dict[str, JsonValue] + + +class _FrozenDict(dict[Any, Any]): + """A JSON-serializable mapping that rejects mutation after construction.""" + + @staticmethod + def _immutable(*_: object, **__: object) -> Never: + raise TypeError("Mission Core Plugin SDK contracts are deeply immutable") + + __setitem__ = _immutable + __delitem__ = _immutable + clear = _immutable + pop = _immutable + popitem = _immutable + setdefault = _immutable + update = _immutable + __ior__ = _immutable + + def __copy__(self) -> _FrozenDict: + return self + + def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenDict: + return self + + +class _FrozenList(list[Any]): + """A JSON-serializable sequence that rejects mutation after construction.""" + + @staticmethod + def _immutable(*_: object, **__: object) -> Never: + raise TypeError("Mission Core Plugin SDK contracts are deeply immutable") + + __setitem__ = _immutable + __delitem__ = _immutable + append = _immutable + clear = _immutable + extend = _immutable + insert = _immutable + pop = _immutable + remove = _immutable + reverse = _immutable + sort = _immutable + __iadd__ = _immutable + __imul__ = _immutable + + def __copy__(self) -> _FrozenList: + return self + + def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenList: + return self + + +def _deep_freeze(value: Any) -> Any: + if isinstance(value, (_FrozenDict, _FrozenList)): + return value + if isinstance(value, dict): + return _FrozenDict({key: _deep_freeze(child) for key, child in value.items()}) + if isinstance(value, list): + return _FrozenList(_deep_freeze(child) for child in value) + if isinstance(value, tuple): + return tuple(_deep_freeze(child) for child in value) + if isinstance(value, (set, frozenset)): + return frozenset(_deep_freeze(child) for child in value) + return value + + +class ContractModel(BaseModel): + """Closed and immutable base for every wire-safe SDK contract.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + @model_validator(mode="after") + def contract_values_are_deeply_immutable(self) -> ContractModel: + """Freeze mutable containers after Pydantic has validated their contents.""" + + for field_name in type(self).model_fields: + value = getattr(self, field_name) + frozen = _deep_freeze(value) + if frozen is not value: + object.__setattr__(self, field_name, frozen) + return self + + def model_copy( + self, + *, + update: Mapping[str, Any] | None = None, + deep: bool = False, + ) -> Self: + """Revalidate updates so a copied contract cannot bypass deep freezing.""" + + if update: + candidate = self.model_dump(round_trip=True, exclude_unset=True) + candidate.update(update) + return type(self).model_validate(candidate) + return super().model_copy(deep=deep) diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/compatibility.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/compatibility.py new file mode 100644 index 0000000..e4c497f --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/compatibility.py @@ -0,0 +1,87 @@ +"""Firmware/profile compatibility decisions that fail closed for active control.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import AwareDatetime, Field, model_validator + +from .common import API_VERSION, ApiVersion, ContractModel, Identifier, LongText, ShortText +from .identity import DeviceInstanceRef + + +class CompatibilityDecision(StrEnum): + COMPATIBLE = "compatible" + LIMITED = "limited" + UNKNOWN = "unknown" + INCOMPATIBLE = "incompatible" + + +class PermittedMode(StrEnum): + BLOCKED = "blocked" + EVIDENCE_ONLY = "evidence-only" + READ_ONLY = "read-only" + ACTIVE_CONTROL = "active-control" + + +class RuleOutcome(StrEnum): + PASS = "pass" + FAIL = "fail" + UNKNOWN = "unknown" + + +class CompatibilityRuleResult(ContractModel): + rule_id: Identifier + outcome: RuleOutcome + blocking: bool + detail: LongText + evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple) + + +class CompatibilityAssessment(ContractModel): + api_version: ApiVersion = API_VERSION + kind: Literal["CompatibilityAssessment"] = "CompatibilityAssessment" + assessment_id: Identifier + device: DeviceInstanceRef + evaluated_at: AwareDatetime + observed_firmware: ShortText | None = None + profile_id: Identifier | None = None + decision: CompatibilityDecision + permitted_mode: PermittedMode + rules: tuple[CompatibilityRuleResult, ...] = Field(min_length=1) + supported_capabilities: tuple[Identifier, ...] = Field(default_factory=tuple) + supported_actions: tuple[Identifier, ...] = Field(default_factory=tuple) + notes: LongText | None = None + + @model_validator(mode="after") + def compatibility_decision_must_fail_closed(self) -> CompatibilityAssessment: + blocking_failures = [ + rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.FAIL + ] + unresolved_blockers = [ + rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.UNKNOWN + ] + if self.decision is CompatibilityDecision.INCOMPATIBLE and ( + self.permitted_mode is not PermittedMode.BLOCKED or not blocking_failures + ): + raise ValueError("incompatible requires blocked mode and a failed blocking rule") + if self.permitted_mode is PermittedMode.ACTIVE_CONTROL: + if self.decision is not CompatibilityDecision.COMPATIBLE: + raise ValueError("active control requires a compatible decision") + if self.profile_id is None: + raise ValueError("active control requires a compatibility profile") + if blocking_failures or unresolved_blockers: + raise ValueError("active control requires every blocking rule to pass") + if self.decision is CompatibilityDecision.UNKNOWN and self.permitted_mode not in { + PermittedMode.BLOCKED, + PermittedMode.EVIDENCE_ONLY, + }: + raise ValueError("unknown compatibility permits only blocked or evidence-only mode") + for label, values in ( + ("capabilities", self.supported_capabilities), + ("actions", self.supported_actions), + ): + if len(values) != len(set(values)): + raise ValueError(f"supported {label} must be unique") + return self diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/evidence.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/evidence.py new file mode 100644 index 0000000..bad58ae --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/evidence.py @@ -0,0 +1,108 @@ +"""Immutable evidence handles, lineage and raw transport records.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal, Protocol, runtime_checkable + +from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator + +from .common import ( + API_VERSION, + ApiVersion, + ContractModel, + Identifier, + JsonObject, + Sha256Digest, + ShortText, +) +from .session import DeviceSessionRef + + +class EvidenceRetention(StrEnum): + EPHEMERAL = "ephemeral" + EXPERIMENT = "experiment" + REGRESSION_FIXTURE = "regression-fixture" + AUDIT = "audit" + + +class RedactionState(StrEnum): + UNREVIEWED = "unreviewed" + REDACTED = "redacted" + CLEARED = "cleared" + RESTRICTED = "restricted" + + +class EvidenceHandle(ContractModel): + evidence_id: Identifier + store_id: Identifier + media_type: ShortText + byte_length: NonNegativeInt + sha256: Sha256Digest + object_key: ShortText + + +class EvidenceRecord(ContractModel): + api_version: ApiVersion = API_VERSION + kind: Literal["EvidenceRecord"] = "EvidenceRecord" + handle: EvidenceHandle + plugin_id: Identifier + session: DeviceSessionRef | None = None + acquisition_id: Identifier | None = None + operation_id: Identifier | None = None + created_at: AwareDatetime + source_kind: Literal[ + "packet-capture", + "transport-payload", + "device-file", + "operator-note", + "derived", + ] + retention: EvidenceRetention + redaction: RedactionState + parent_evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple) + metadata: JsonObject = Field(default_factory=dict) + + @model_validator(mode="after") + def lineage_must_be_acyclic_at_record_boundary(self) -> EvidenceRecord: + if self.handle.evidence_id in self.parent_evidence_ids: + raise ValueError("an evidence record cannot name itself as a parent") + if len(self.parent_evidence_ids) != len(set(self.parent_evidence_ids)): + raise ValueError("parent evidence ids must be unique") + return self + + +class RawTransportRecord(ContractModel): + """A raw-first observation; decoding produces derived evidence, never replacement.""" + + api_version: ApiVersion = API_VERSION + kind: Literal["RawTransportRecord"] = "RawTransportRecord" + record_id: Identifier + session: DeviceSessionRef + profile_id: Identifier | None = None + observed_at: AwareDatetime + transport: Identifier + direction: Literal["device-to-host", "host-to-device", "peer-to-peer", "unknown"] + channel: ShortText + sequence: NonNegativeInt | None = None + payload: EvidenceHandle + metadata: JsonObject = Field(default_factory=dict) + + +@runtime_checkable +class EvidenceStore(Protocol): + """Minimal store boundary; implementations own persistence and access policy.""" + + def put_bytes( + self, + *, + evidence_id: str, + media_type: str, + payload: bytes, + ) -> EvidenceHandle: ... + + def register(self, record: EvidenceRecord) -> None: ... + + def get_record(self, evidence_id: str) -> EvidenceRecord: ... + + def open_bytes(self, handle: EvidenceHandle) -> bytes: ... diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/identity.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/identity.py new file mode 100644 index 0000000..a7e9672 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/identity.py @@ -0,0 +1,74 @@ +"""Stable device identity contracts. + +Transport-local names (for example a CoreBluetooth UUID assigned by one Mac) +may be recorded as aliases, but cannot be asserted as a stable device identity. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, Field, model_validator + +from .common import ContractModel, Identifier, ShortText + + +class IdentityStability(StrEnum): + STABLE = "stable" + PROVISIONAL = "provisional" + + +class IdentityBasis(StrEnum): + HARDWARE_IDENTIFIER = "hardware-identifier" + PLUGIN_DERIVED = "plugin-derived" + OPERATOR_ASSIGNED = "operator-assigned" + TRANSPORT_LOCAL = "transport-local" + + +class ExecutionPlatform(StrEnum): + MACOS = "macos" + LINUX = "linux" + WINDOWS = "windows" + UNKNOWN = "unknown" + + +class DeviceModelRef(ContractModel): + plugin_id: Identifier + plugin_version: ShortText + model_id: Identifier + + +class TransportAlias(ContractModel): + """A non-authoritative address observed in one transport scope.""" + + transport: Identifier + scope_id: Identifier + value: ShortText + observed_at: AwareDatetime + + +class DeviceInstanceRef(ContractModel): + """Plugin-scoped device identity, independent from a connection session.""" + + device_id: Identifier + model: DeviceModelRef + stability: IdentityStability + basis: IdentityBasis + aliases: tuple[TransportAlias, ...] = Field(default_factory=tuple) + + @model_validator(mode="after") + def stable_identity_must_not_be_transport_local(self) -> DeviceInstanceRef: + if ( + self.stability is IdentityStability.STABLE + and self.basis is IdentityBasis.TRANSPORT_LOCAL + ): + raise ValueError("a transport-local identifier cannot be a stable device identity") + return self + + +class ExecutionBinding(ContractModel): + """The logical node and concrete agent process hosting a device session.""" + + node_id: Identifier + agent_instance_id: Identifier + platform: ExecutionPlatform diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/operations.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/operations.py new file mode 100644 index 0000000..01d8e47 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/operations.py @@ -0,0 +1,187 @@ +"""Auditable device-operation requests and lifecycle events.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import ( + AwareDatetime, + Field, + JsonValue, + NonNegativeInt, + PositiveFloat, + model_validator, +) + +from .common import ( + API_VERSION, + ApiVersion, + ContractModel, + Identifier, + JsonObject, + LongText, + ShortText, +) +from .session import DeviceSessionRef + + +class OperationSafetyClass(StrEnum): + READ_ONLY = "read-only" + REVERSIBLE = "reversible" + STATE_CHANGING = "state-changing" + SAFETY_CRITICAL = "safety-critical" + + +class IdempotencyMode(StrEnum): + IDEMPOTENT = "idempotent" + IDEMPOTENT_WITH_KEY = "idempotent-with-key" + NON_IDEMPOTENT = "non-idempotent" + + +class SecretReference(ContractModel): + """Opaque secret-vault reference; secret material is never an SDK value.""" + + provider: Identifier + reference: ShortText + version: ShortText | None = None + + +class OperationPolicy(ContractModel): + action_id: Identifier + safety_class: OperationSafetyClass + idempotency: IdempotencyMode + timeout_seconds: PositiveFloat + acknowledgement_required: bool = True + retry_limit: NonNegativeInt = 0 + preconditions: tuple[Identifier, ...] = Field(default_factory=tuple) + secret_fields: tuple[Identifier, ...] = Field(default_factory=tuple) + + @model_validator(mode="after") + def policy_collections_must_be_unique(self) -> OperationPolicy: + for label, values in ( + ("preconditions", self.preconditions), + ("secret fields", self.secret_fields), + ): + if len(values) != len(set(values)): + raise ValueError(f"{label} must be unique") + if self.idempotency is IdempotencyMode.NON_IDEMPOTENT and self.retry_limit: + raise ValueError("non-idempotent operations cannot declare automatic retries") + return self + + +class OperationRequest(ContractModel): + api_version: ApiVersion = API_VERSION + kind: Literal["OperationRequest"] = "OperationRequest" + operation_id: Identifier + session: DeviceSessionRef + action_id: Identifier + requested_at: AwareDatetime + deadline_at: AwareDatetime + idempotency_key: Identifier | None = None + parameters: JsonObject = Field(default_factory=dict) + secret_refs: dict[Identifier, SecretReference] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_request_boundaries(self) -> OperationRequest: + if self.deadline_at <= self.requested_at: + raise ValueError("deadline_at must be later than requested_at") + overlap = set(self.parameters).intersection(self.secret_refs) + if overlap: + raise ValueError(f"secret fields cannot also appear in parameters: {sorted(overlap)}") + return self + + +class AcknowledgementDisposition(StrEnum): + ACCEPTED = "accepted" + REJECTED = "rejected" + DUPLICATE = "duplicate" + + +class OperationAcknowledgement(ContractModel): + """Receipt/admission acknowledgement; never proof of operation completion.""" + + api_version: ApiVersion = API_VERSION + event_type: Literal["acknowledgement"] = "acknowledgement" + operation_id: Identifier + session: DeviceSessionRef + sequence: NonNegativeInt + recorded_at: AwareDatetime + disposition: AcknowledgementDisposition + device_receipt_id: Identifier | None = None + message: LongText | None = None + + +class OperationProgress(ContractModel): + api_version: ApiVersion = API_VERSION + event_type: Literal["progress"] = "progress" + operation_id: Identifier + session: DeviceSessionRef + sequence: NonNegativeInt + recorded_at: AwareDatetime + stage: Identifier + progress: Annotated[float, Field(ge=0.0, le=1.0)] | None = None + message: LongText | None = None + measurements: dict[str, JsonValue] = Field(default_factory=dict) + + +class OperationCompletion(ContractModel): + api_version: ApiVersion = API_VERSION + event_type: Literal["completion"] = "completion" + operation_id: Identifier + session: DeviceSessionRef + sequence: NonNegativeInt + recorded_at: AwareDatetime + result: JsonObject = Field(default_factory=dict) + evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple) + + +class OperationTerminalState(StrEnum): + FAILED = "failed" + CANCELLED = "cancelled" + TIMED_OUT = "timed-out" + + +class OperationError(ContractModel): + code: Identifier + message: LongText + retryable: bool + details: JsonObject = Field(default_factory=dict) + + +class OperationFailure(ContractModel): + api_version: ApiVersion = API_VERSION + event_type: Literal["failure"] = "failure" + operation_id: Identifier + session: DeviceSessionRef + sequence: NonNegativeInt + recorded_at: AwareDatetime + terminal_state: OperationTerminalState + error: OperationError + evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple) + + +OperationEvent = Annotated[ + OperationAcknowledgement | OperationProgress | OperationCompletion | OperationFailure, + Field(discriminator="event_type"), +] + + +def validate_operation_request(policy: OperationPolicy, request: OperationRequest) -> None: + """Validate request data that depends on its declared action policy.""" + + if request.action_id != policy.action_id: + raise ValueError("operation request action_id does not match its policy") + declared_secrets = set(policy.secret_fields) + supplied_secrets = set(request.secret_refs) + missing = declared_secrets - supplied_secrets + undeclared = supplied_secrets - declared_secrets + if missing: + raise ValueError(f"missing secret references: {sorted(missing)}") + if undeclared: + raise ValueError(f"undeclared secret references: {sorted(undeclared)}") + if policy.idempotency is IdempotencyMode.IDEMPOTENT_WITH_KEY: + if request.idempotency_key is None: + raise ValueError("idempotent-with-key operation requires idempotency_key") + elif request.idempotency_key is not None: + raise ValueError("idempotency_key is allowed only for idempotent-with-key operations") diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/payloads.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/payloads.py new file mode 100644 index 0000000..6bca47c --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/payloads.py @@ -0,0 +1,62 @@ +"""Language-neutral binary payload handles used by canonical stream contracts.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +from typing import Annotated, Literal + +from pydantic import Field, NonNegativeInt, PositiveInt, model_validator + +from .common import ContractModel, Identifier, Sha256Digest, ShortText + + +class InlinePayload(ContractModel): + kind: Literal["inline-base64"] = "inline-base64" + media_type: ShortText + byte_length: NonNegativeInt + sha256: Sha256Digest + data: str + + @model_validator(mode="after") + def inline_data_must_match_descriptor(self) -> InlinePayload: + try: + decoded = base64.b64decode(self.data, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("data must be canonical base64") from exc + if len(decoded) != self.byte_length: + raise ValueError("byte_length does not match decoded inline payload") + digest = f"sha256:{hashlib.sha256(decoded).hexdigest()}" + if digest != self.sha256: + raise ValueError("sha256 does not match decoded inline payload") + if base64.b64encode(decoded).decode("ascii") != self.data: + raise ValueError("data must use canonical padded base64 encoding") + return self + + +class ReferencedPayload(ContractModel): + """Opaque payload location owned by a declared transport or evidence store.""" + + kind: Literal["reference"] = "reference" + media_type: ShortText + byte_length: NonNegativeInt + sha256: Sha256Digest | None = None + reference_scheme: Literal["evidence", "shared-memory", "transport", "uri"] + locator: ShortText + offset: NonNegativeInt = 0 + segment_length: PositiveInt | None = None + owner_id: Identifier | None = None + + @model_validator(mode="after") + def segment_must_fit_known_payload(self) -> ReferencedPayload: + if self.offset > self.byte_length: + raise ValueError("offset cannot exceed byte_length") + if self.segment_length is not None and self.offset + self.segment_length > self.byte_length: + raise ValueError("referenced segment exceeds byte_length") + if self.reference_scheme in {"evidence", "shared-memory"} and self.owner_id is None: + raise ValueError("evidence and shared-memory references require owner_id") + return self + + +PayloadHandle = Annotated[InlinePayload | ReferencedPayload, Field(discriminator="kind")] diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py new file mode 100644 index 0000000..29e5fcc --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py @@ -0,0 +1,30 @@ +"""Deterministic JSON Schema export for non-Python SDK consumers.""" + +from __future__ import annotations + +from pydantic import TypeAdapter + +from .compatibility import CompatibilityAssessment +from .evidence import EvidenceRecord, RawTransportRecord +from .identity import DeviceInstanceRef +from .operations import OperationEvent, OperationPolicy, OperationRequest +from .session import DeviceSessionContext, DeviceSessionSnapshot +from .streams import CanonicalStreamEnvelope + + +def contract_json_schemas() -> dict[str, dict[str, object]]: + """Return named schemas; callers may persist or serve the resulting bundle.""" + + contracts: dict[str, object] = { + "DeviceInstanceRef": DeviceInstanceRef, + "DeviceSessionContext": DeviceSessionContext, + "DeviceSessionSnapshot": DeviceSessionSnapshot, + "OperationPolicy": OperationPolicy, + "OperationRequest": OperationRequest, + "OperationEvent": OperationEvent, + "CanonicalStreamEnvelope": CanonicalStreamEnvelope, + "EvidenceRecord": EvidenceRecord, + "RawTransportRecord": RawTransportRecord, + "CompatibilityAssessment": CompatibilityAssessment, + } + return {name: TypeAdapter(contract).json_schema() for name, contract in contracts.items()} diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/session.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/session.py new file mode 100644 index 0000000..0570f88 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/session.py @@ -0,0 +1,70 @@ +"""Independent device-session lifecycle contracts.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator + +from .common import ContractModel, Identifier, LongText +from .identity import DeviceInstanceRef, ExecutionBinding + + +class EnrollmentState(StrEnum): + EMPTY = "empty" + SELECTING_MODEL = "selecting-model" + SETUP_IN_PROGRESS = "setup-in-progress" + ENROLLED = "enrolled" + FAILED = "failed" + + +class ConnectivityState(StrEnum): + UNKNOWN = "unknown" + OFFLINE = "offline" + CONNECTING = "connecting" + CONNECTED = "connected" + DEGRADED = "degraded" + + +class AcquisitionState(StrEnum): + IDLE = "idle" + PREPARING = "preparing" + STARTING = "starting" + STREAMING = "streaming" + STOPPING = "stopping" + FAILED = "failed" + + +class DeviceSessionContext(ContractModel): + """Immutable identity and binding established when a plugin opens a session.""" + + session_id: Identifier + device: DeviceInstanceRef + execution: ExecutionBinding + compatibility_profile_id: Identifier | None = None + compatibility_assessment_id: Identifier | None = None + opened_at: AwareDatetime + + +class DeviceSessionRef(ContractModel): + session_id: Identifier + device_id: Identifier + + +class DeviceSessionSnapshot(ContractModel): + """Revisioned state; enrollment, connectivity and acquisition never alias.""" + + context: DeviceSessionContext + revision: NonNegativeInt + enrollment: EnrollmentState + connectivity: ConnectivityState + acquisition: AcquisitionState + observed_at: AwareDatetime + message: LongText | None = None + active_operation_ids: tuple[Identifier, ...] = Field(default_factory=tuple) + + @model_validator(mode="after") + def operation_ids_must_be_unique(self) -> DeviceSessionSnapshot: + if len(self.active_operation_ids) != len(set(self.active_operation_ids)): + raise ValueError("active operation ids must be unique") + return self diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/streams.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/streams.py new file mode 100644 index 0000000..012fbce --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/streams.py @@ -0,0 +1,174 @@ +"""Vendor-neutral canonical stream envelopes.""" + +from __future__ import annotations + +import math +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import AwareDatetime, Field, NonNegativeInt, PositiveInt, model_validator + +from .common import API_VERSION, ApiVersion, ContractModel, Identifier, JsonObject, ShortText +from .payloads import PayloadHandle +from .session import DeviceSessionRef + + +class ClockDomain(StrEnum): + UTC = "utc" + DEVICE_MONOTONIC = "device-monotonic" + HOST_MONOTONIC = "host-monotonic" + UNKNOWN = "unknown" + + +class CanonicalStreamHeader(ContractModel): + api_version: ApiVersion = API_VERSION + message_id: Identifier + session: DeviceSessionRef + acquisition_id: Identifier | None = None + source_id: Identifier + channel_id: Identifier + sequence: NonNegativeInt + captured_at: AwareDatetime + observed_at: AwareDatetime + clock_domain: ClockDomain + compatibility_profile_id: Identifier | None = None + + +class PointFieldType(StrEnum): + INT8 = "int8" + UINT8 = "uint8" + INT16 = "int16" + UINT16 = "uint16" + INT32 = "int32" + UINT32 = "uint32" + FLOAT32 = "float32" + FLOAT64 = "float64" + + +_POINT_FIELD_BYTES = { + PointFieldType.INT8: 1, + PointFieldType.UINT8: 1, + PointFieldType.INT16: 2, + PointFieldType.UINT16: 2, + PointFieldType.INT32: 4, + PointFieldType.UINT32: 4, + PointFieldType.FLOAT32: 4, + PointFieldType.FLOAT64: 8, +} + + +class PointField(ContractModel): + name: Identifier + offset: NonNegativeInt + data_type: PointFieldType + count: PositiveInt = 1 + + +class PointCloudFrame(ContractModel): + kind: Literal["point-cloud"] = "point-cloud" + header: CanonicalStreamHeader + coordinate_frame: Identifier + width: PositiveInt + height: PositiveInt = 1 + point_count: PositiveInt + point_step: PositiveInt + row_step: PositiveInt + little_endian: bool = True + dense: bool + fields: tuple[PointField, ...] = Field(min_length=3) + payload: PayloadHandle + + @model_validator(mode="after") + def point_layout_must_be_self_consistent(self) -> PointCloudFrame: + if self.point_count != self.width * self.height: + raise ValueError("point_count must equal width * height") + if self.row_step != self.point_step * self.width: + raise ValueError("row_step must equal point_step * width") + if self.payload.byte_length != self.row_step * self.height: + raise ValueError("payload byte_length must match the declared point layout") + names = [field.name for field in self.fields] + if len(names) != len(set(names)): + raise ValueError("point field names must be unique") + if not {"x", "y", "z"}.issubset(names): + raise ValueError("canonical point clouds require x, y, and z fields") + for field in self.fields: + field_end = field.offset + _POINT_FIELD_BYTES[field.data_type] * field.count + if field_end > self.point_step: + raise ValueError(f"point field {field.name} exceeds point_step") + return self + + +class Vector3(ContractModel): + x: float + y: float + z: float + + +class Quaternion(ContractModel): + x: float + y: float + z: float + w: float + + @model_validator(mode="after") + def quaternion_must_be_normalized(self) -> Quaternion: + norm = math.sqrt(self.x**2 + self.y**2 + self.z**2 + self.w**2) + if not math.isclose(norm, 1.0, rel_tol=1e-3, abs_tol=1e-3): + raise ValueError("quaternion must be normalized") + return self + + +class PoseFrame(ContractModel): + kind: Literal["pose"] = "pose" + header: CanonicalStreamHeader + parent_frame: Identifier + child_frame: Identifier + translation_m: Vector3 + rotation: Quaternion + covariance: tuple[float, ...] | None = None + + @model_validator(mode="after") + def covariance_must_be_six_by_six(self) -> PoseFrame: + if self.covariance is not None and len(self.covariance) != 36: + raise ValueError("pose covariance must contain 36 values") + return self + + +class ImageFrame(ContractModel): + kind: Literal["image"] = "image" + header: CanonicalStreamHeader + camera_id: Identifier + optical_frame: Identifier + width: PositiveInt + height: PositiveInt + pixel_format: Literal["rgb8", "bgr8", "rgba8", "mono8", "mono16", "depth16", "jpeg", "png"] + payload: PayloadHandle + calibration_id: Identifier | None = None + + +class EncodedVideoPacket(ContractModel): + kind: Literal["encoded-video"] = "encoded-video" + header: CanonicalStreamHeader + stream_id: Identifier + camera_id: Identifier + codec: Literal["h264", "h265", "mjpeg", "av1", "unknown"] + key_frame: bool + presentation_time_ns: NonNegativeInt + decode_time_ns: NonNegativeInt | None = None + duration_ns: NonNegativeInt | None = None + payload: PayloadHandle + + +class DeviceStatusFrame(ContractModel): + kind: Literal["device-status"] = "device-status" + header: CanonicalStreamHeader + health: Literal["unknown", "healthy", "degraded", "fault"] + state: Identifier + measurements: JsonObject = Field(default_factory=dict) + message: ShortText | None = None + + +CanonicalStreamEnvelope = Annotated[ + PointCloudFrame | PoseFrame | ImageFrame | EncodedVideoPacket | DeviceStatusFrame, + Field(discriminator="kind"), +] diff --git a/pyproject.toml b/pyproject.toml index bb8372f..6e19192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "fastapi>=0.116,<1", "foxglove-sdk==0.25.3", "lz4>=4.4,<5", + "missioncore-plugin-sdk", "paho-mqtt>=2.1,<3", "rerun-sdk==0.34.1", "rich>=13.9,<15", @@ -22,6 +23,9 @@ dependencies = [ "uvicorn[standard]>=0.35,<1", ] +[tool.uv.sources] +missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true } + [project.scripts] k1link = "k1link.cli:app" diff --git a/tests/test_plugin_sdk_v0alpha2_contracts.py b/tests/test_plugin_sdk_v0alpha2_contracts.py new file mode 100644 index 0000000..d361e6a --- /dev/null +++ b/tests/test_plugin_sdk_v0alpha2_contracts.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from missioncore_plugin_sdk import v0alpha2 as sdk +from pydantic import TypeAdapter, ValidationError + +NOW = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + + +def _model() -> sdk.DeviceModelRef: + return sdk.DeviceModelRef( + plugin_id="nodedc.device.synthetic", + plugin_version="0.2.0", + model_id="synthetic.sensor", + ) + + +def _device() -> sdk.DeviceInstanceRef: + return sdk.DeviceInstanceRef( + device_id="synthetic.device-001", + model=_model(), + stability=sdk.IdentityStability.STABLE, + basis=sdk.IdentityBasis.PLUGIN_DERIVED, + ) + + +def _session_ref() -> sdk.DeviceSessionRef: + return sdk.DeviceSessionRef( + session_id="session-001", + device_id="synthetic.device-001", + ) + + +def _header() -> sdk.CanonicalStreamHeader: + return sdk.CanonicalStreamHeader( + message_id="message-001", + session=_session_ref(), + acquisition_id="acquisition-001", + source_id="lidar-primary", + channel_id="points", + sequence=1, + captured_at=NOW, + observed_at=NOW + timedelta(milliseconds=3), + clock_domain=sdk.ClockDomain.UTC, + compatibility_profile_id="synthetic.fw-1.v1", + ) + + +def _inline_payload(data: bytes) -> sdk.InlinePayload: + return sdk.InlinePayload( + media_type="application/x-pointcloud-struct", + byte_length=len(data), + sha256=f"sha256:{hashlib.sha256(data).hexdigest()}", + data=base64.b64encode(data).decode("ascii"), + ) + + +def test_plugin_sdk_v0alpha2_identity_separates_transport_alias_from_stable_id() -> None: + alias = sdk.TransportAlias( + transport="ble.corebluetooth", + scope_id="agent.macbook-01", + value="SYNTHETIC-K1-0001", + observed_at=NOW, + ) + provisional = sdk.DeviceInstanceRef( + device_id="observation.ble-synthetic-k1-0001", + model=_model(), + stability=sdk.IdentityStability.PROVISIONAL, + basis=sdk.IdentityBasis.TRANSPORT_LOCAL, + aliases=(alias,), + ) + + assert provisional.aliases == (alias,) + with pytest.raises(ValidationError, match="transport-local identifier"): + sdk.DeviceInstanceRef( + device_id="incorrectly-stable", + model=_model(), + stability=sdk.IdentityStability.STABLE, + basis=sdk.IdentityBasis.TRANSPORT_LOCAL, + ) + + +def test_plugin_sdk_v0alpha2_session_tracks_three_independent_state_machines() -> None: + context = sdk.DeviceSessionContext( + session_id="session-001", + device=_device(), + execution=sdk.ExecutionBinding( + node_id="mission-node.local-01", + agent_instance_id="agent-process-001", + platform=sdk.ExecutionPlatform.MACOS, + ), + compatibility_profile_id="synthetic.fw-1.v1", + opened_at=NOW, + ) + snapshot = sdk.DeviceSessionSnapshot( + context=context, + revision=4, + enrollment=sdk.EnrollmentState.ENROLLED, + connectivity=sdk.ConnectivityState.CONNECTED, + acquisition=sdk.AcquisitionState.IDLE, + observed_at=NOW, + active_operation_ids=("operation-health",), + ) + + assert snapshot.enrollment is sdk.EnrollmentState.ENROLLED + assert snapshot.connectivity is sdk.ConnectivityState.CONNECTED + assert snapshot.acquisition is sdk.AcquisitionState.IDLE + with pytest.raises(ValidationError, match="active operation ids must be unique"): + sdk.DeviceSessionSnapshot.model_validate( + { + **snapshot.model_dump(), + "active_operation_ids": ("operation-health", "operation-health"), + } + ) + + +def test_plugin_sdk_v0alpha2_operation_ack_is_not_completion() -> None: + policy = sdk.OperationPolicy( + action_id="acquisition.start", + safety_class=sdk.OperationSafetyClass.STATE_CHANGING, + idempotency=sdk.IdempotencyMode.IDEMPOTENT_WITH_KEY, + timeout_seconds=30, + retry_limit=1, + preconditions=("device.connected", "calibration.ready"), + secret_fields=("device_credential",), + ) + request = sdk.OperationRequest( + operation_id="operation-start-001", + session=_session_ref(), + action_id="acquisition.start", + requested_at=NOW, + deadline_at=NOW + timedelta(seconds=30), + idempotency_key="acquisition.start:001", + parameters={"project_id": "project-001"}, + secret_refs={ + "device_credential": sdk.SecretReference( + provider="missioncore.secret-store", + reference="devices/synthetic-001/credential", + ) + }, + ) + sdk.validate_operation_request(policy, request) + + adapter = TypeAdapter(sdk.OperationEvent) + acknowledgement = adapter.validate_python( + { + "event_type": "acknowledgement", + "operation_id": request.operation_id, + "session": request.session.model_dump(), + "sequence": 0, + "recorded_at": NOW, + "disposition": "accepted", + } + ) + completion = adapter.validate_python( + { + "event_type": "completion", + "operation_id": request.operation_id, + "session": request.session.model_dump(), + "sequence": 2, + "recorded_at": NOW + timedelta(seconds=1), + "result": {"acquisition_id": "acquisition-001"}, + } + ) + + assert isinstance(acknowledgement, sdk.OperationAcknowledgement) + assert isinstance(completion, sdk.OperationCompletion) + assert acknowledgement.event_type != completion.event_type + + +def test_plugin_sdk_v0alpha2_operation_policy_fails_closed() -> None: + with pytest.raises(ValidationError, match="automatic retries"): + sdk.OperationPolicy( + action_id="calibration.start", + safety_class=sdk.OperationSafetyClass.SAFETY_CRITICAL, + idempotency=sdk.IdempotencyMode.NON_IDEMPOTENT, + timeout_seconds=60, + retry_limit=1, + ) + + policy = sdk.OperationPolicy( + action_id="camera.catalog.read", + safety_class=sdk.OperationSafetyClass.READ_ONLY, + idempotency=sdk.IdempotencyMode.IDEMPOTENT, + timeout_seconds=5, + ) + request = sdk.OperationRequest( + operation_id="operation-camera-001", + session=_session_ref(), + action_id="camera.preview.start", + requested_at=NOW, + deadline_at=NOW + timedelta(seconds=5), + ) + with pytest.raises(ValueError, match="action_id"): + sdk.validate_operation_request(policy, request) + + +def test_plugin_sdk_v0alpha2_point_cloud_layout_and_payload_are_verified() -> None: + payload = _inline_payload(b"\0" * 24) + frame = sdk.PointCloudFrame( + header=_header(), + coordinate_frame="sensor.lidar-primary", + width=2, + point_count=2, + point_step=12, + row_step=24, + dense=False, + fields=( + sdk.PointField(name="x", offset=0, data_type=sdk.PointFieldType.FLOAT32), + sdk.PointField(name="y", offset=4, data_type=sdk.PointFieldType.FLOAT32), + sdk.PointField(name="z", offset=8, data_type=sdk.PointFieldType.FLOAT32), + ), + payload=payload, + ) + + assert frame.payload.byte_length == frame.point_count * frame.point_step + with pytest.raises(ValidationError, match="point_count"): + sdk.PointCloudFrame.model_validate({**frame.model_dump(), "point_count": 3}) + with pytest.raises(ValidationError, match="sha256"): + sdk.InlinePayload.model_validate({**payload.model_dump(), "sha256": f"sha256:{'0' * 64}"}) + + +def test_plugin_sdk_v0alpha2_evidence_is_immutable_and_lineage_is_explicit() -> None: + handle = sdk.EvidenceHandle( + evidence_id="evidence.raw-001", + store_id="evidence-store.local", + media_type="application/vnd.tcpdump.pcap", + byte_length=1024, + sha256=f"sha256:{'a' * 64}", + object_key="experiments/2026-07-16/raw-001.pcap", + ) + record = sdk.EvidenceRecord( + handle=handle, + plugin_id="nodedc.device.synthetic", + session=_session_ref(), + created_at=NOW, + source_kind="packet-capture", + retention=sdk.EvidenceRetention.EXPERIMENT, + redaction=sdk.RedactionState.UNREVIEWED, + ) + + assert record.handle.sha256.startswith("sha256:") + with pytest.raises(ValidationError, match="cannot name itself"): + sdk.EvidenceRecord.model_validate( + {**record.model_dump(), "parent_evidence_ids": [handle.evidence_id]} + ) + with pytest.raises(ValidationError, match="frozen"): + record.redaction = sdk.RedactionState.CLEARED # type: ignore[misc] + + +def test_plugin_sdk_v0alpha2_contracts_are_deeply_immutable() -> None: + source_parameters = { + "pipeline": { + "stages": ["capture", "decode"], + "options": {"raw_first": True}, + } + } + request = sdk.OperationRequest( + operation_id="operation-immutable-001", + session=_session_ref(), + action_id="acquisition.prepare", + requested_at=NOW, + deadline_at=NOW + timedelta(seconds=30), + parameters=source_parameters, + ) + + pipeline = request.parameters["pipeline"] + assert isinstance(pipeline, dict) + stages = pipeline["stages"] + assert isinstance(stages, list) + options = pipeline["options"] + assert isinstance(options, dict) + + with pytest.raises(TypeError, match="deeply immutable"): + request.parameters["new"] = True + with pytest.raises(TypeError, match="deeply immutable"): + options["raw_first"] = False + with pytest.raises(TypeError, match="deeply immutable"): + stages.append("publish") + + source_parameters["pipeline"]["stages"].append("outside-mutation") + assert stages == ["capture", "decode"] + wire_document = request.model_dump(mode="json") + assert wire_document["parameters"] == { + "pipeline": { + "stages": ["capture", "decode"], + "options": {"raw_first": True}, + } + } + assert json.loads(json.dumps(wire_document))["parameters"] == wire_document["parameters"] + assert "parameters" in sdk.OperationRequest.model_json_schema()["properties"] + + copy_update = {"parameters": {"pipeline": {"stages": ["publish"]}}} + copied = request.model_copy(update=copy_update) + copied_stages = copied.parameters["pipeline"]["stages"] + assert copied_stages == ["publish"] + with pytest.raises(TypeError, match="deeply immutable"): + copied_stages.append("mutate") + copy_update["parameters"]["pipeline"]["stages"].append("outside-mutation") + assert copied_stages == ["publish"] + with pytest.raises(ValidationError, match="extra"): + request.model_copy(update={"undeclared": True}) + + +def test_plugin_sdk_v0alpha2_compatibility_blocks_unproven_control() -> None: + pass_rule = sdk.CompatibilityRuleResult( + rule_id="firmware.exact-match", + outcome=sdk.RuleOutcome.PASS, + blocking=True, + detail="Firmware and protocol fixture match the reviewed profile.", + ) + assessment = sdk.CompatibilityAssessment( + assessment_id="compatibility-001", + device=_device(), + evaluated_at=NOW, + observed_firmware="1.0.0", + profile_id="synthetic.fw-1.v1", + decision=sdk.CompatibilityDecision.COMPATIBLE, + permitted_mode=sdk.PermittedMode.ACTIVE_CONTROL, + rules=(pass_rule,), + supported_actions=("acquisition.start", "acquisition.stop"), + ) + assert assessment.permitted_mode is sdk.PermittedMode.ACTIVE_CONTROL + + with pytest.raises(ValidationError, match="active control requires a compatible decision"): + sdk.CompatibilityAssessment.model_validate( + { + **assessment.model_dump(), + "decision": "unknown", + "permitted_mode": "active-control", + "profile_id": None, + } + ) + + +def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> None: + schemas = sdk.contract_json_schemas() + + assert { + "DeviceInstanceRef", + "DeviceSessionSnapshot", + "OperationEvent", + "CanonicalStreamEnvelope", + "EvidenceRecord", + "CompatibilityAssessment", + }.issubset(schemas) + assert "oneOf" in schemas["OperationEvent"] + with pytest.raises(ValidationError, match="extra"): + sdk.DeviceSessionRef.model_validate( + {"session_id": "session-001", "device_id": "device-001", "vendor": "forbidden"} + ) + + repository_root = Path(__file__).resolve().parents[1] + vocabulary = ( + repository_root / "docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md" + ).read_text(encoding="utf-8") + assert "acknowledgement is not operation completion" in vocabulary + assert "not yet a NODE.DC platform ontology" in vocabulary diff --git a/uv.lock b/uv.lock index cc93a09..6d34190 100644 --- a/uv.lock +++ b/uv.lock @@ -237,6 +237,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "missioncore-plugin-sdk" +version = "0.2.0a2" +source = { editable = "packages/plugin-sdk" } +dependencies = [ + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [{ name = "pydantic", specifier = ">=2.11,<3" }] + [[package]] name = "mypy" version = "1.20.2" @@ -277,6 +288,7 @@ dependencies = [ { name = "fastapi" }, { name = "foxglove-sdk" }, { name = "lz4" }, + { name = "missioncore-plugin-sdk" }, { name = "paho-mqtt" }, { name = "rerun-sdk" }, { name = "rich" }, @@ -297,6 +309,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.116,<1" }, { name = "foxglove-sdk", specifier = "==0.25.3" }, { name = "lz4", specifier = ">=4.4,<5" }, + { name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" }, { name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "rerun-sdk", specifier = "==0.34.1" }, { name = "rich", specifier = ">=13.9,<15" },