feat(plugin-sdk): add executable v0alpha2 contracts

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 19:43:26 +03:00
parent 27bf7527df
commit 63dd8c8790
18 changed files with 1634 additions and 26 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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"]

View File

@ -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"]

View File

@ -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",
]

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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: ...

View File

@ -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

View File

@ -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")

View File

@ -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")]

View File

@ -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()}

View File

@ -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

View File

@ -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"),
]

View File

@ -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"

View File

@ -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

13
uv.lock
View File

@ -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" },