feat(plugin-sdk): add executable v0alpha2 contracts
This commit is contained in:
parent
27bf7527df
commit
63dd8c8790
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
13
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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue