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
+57 -26
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.
+15
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"]
@@ -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"]
@@ -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",
]
@@ -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))
@@ -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)
@@ -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
@@ -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: ...
@@ -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
@@ -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")
@@ -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")]
@@ -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()}
@@ -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
@@ -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"),
]