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
|
# Mission Core Plugin SDK
|
||||||
|
|
||||||
This directory owns the versioned host/plugin contract. The v1alpha frontend
|
This directory owns the versioned host/plugin contract. It is an independently
|
||||||
contract, validated registry, generic runtime envelope, and backend read-only
|
installable in-tree package; it is not yet published to a package registry.
|
||||||
catalog are implemented in-tree, but this is not yet a separately published
|
|
||||||
SDK package.
|
|
||||||
|
|
||||||
The v1alpha1 contract currently validates one model per plugin, one reviewed
|
`missioncore_plugin_sdk.v0alpha2` is the first executable backend-neutral
|
||||||
`transitional-in-process` backend entrypoint, a required safe `state.read`
|
contract layer. It uses closed, deeply immutable Pydantic models and exports
|
||||||
action, and declared UI/action metadata. It establishes:
|
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
|
```python
|
||||||
permissions;
|
from missioncore_plugin_sdk import v0alpha2 as sdk
|
||||||
- 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.
|
|
||||||
|
|
||||||
The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic
|
request = sdk.OperationRequest.model_validate(document)
|
||||||
multi-plugin composition tests verify that backend routing has no K1 identity or
|
schemas = sdk.contract_json_schemas()
|
||||||
protocol assumption; static boundary tests enforce the same rule in the frontend.
|
```
|
||||||
|
|
||||||
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
|
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
|
||||||
contribution contracts;
|
contribution contracts;
|
||||||
|
|
@ -31,13 +55,20 @@ Current implementation references:
|
||||||
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
||||||
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
|
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
|
||||||
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
- `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
|
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
|
||||||
and extraction sequence.
|
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
|
I/O, events are scoped by plugin ID, and selection cannot change until the
|
||||||
active plugin confirms teardown. Process isolation and independent device
|
active plugin confirms teardown. Process isolation remains a later host
|
||||||
session IDs remain the next SDK milestone. `permissions`, `mutating`, and
|
milestone. v0alpha2 models secret references and operation policy, but host
|
||||||
`secretFields` are contract metadata only in v1alpha1; host authorization and
|
authorization and the actual secret vault remain separate implementation
|
||||||
secret-vault enforcement are not implemented yet.
|
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",
|
"fastapi>=0.116,<1",
|
||||||
"foxglove-sdk==0.25.3",
|
"foxglove-sdk==0.25.3",
|
||||||
"lz4>=4.4,<5",
|
"lz4>=4.4,<5",
|
||||||
|
"missioncore-plugin-sdk",
|
||||||
"paho-mqtt>=2.1,<3",
|
"paho-mqtt>=2.1,<3",
|
||||||
"rerun-sdk==0.34.1",
|
"rerun-sdk==0.34.1",
|
||||||
"rich>=13.9,<15",
|
"rich>=13.9,<15",
|
||||||
|
|
@ -22,6 +23,9 @@ dependencies = [
|
||||||
"uvicorn[standard]>=0.35,<1",
|
"uvicorn[standard]>=0.35,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
k1link = "k1link.cli:app"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.20.2"
|
version = "1.20.2"
|
||||||
|
|
@ -277,6 +288,7 @@ dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "foxglove-sdk" },
|
{ name = "foxglove-sdk" },
|
||||||
{ name = "lz4" },
|
{ name = "lz4" },
|
||||||
|
{ name = "missioncore-plugin-sdk" },
|
||||||
{ name = "paho-mqtt" },
|
{ name = "paho-mqtt" },
|
||||||
{ name = "rerun-sdk" },
|
{ name = "rerun-sdk" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
|
|
@ -297,6 +309,7 @@ requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||||
|
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
|
||||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||||
{ name = "rerun-sdk", specifier = "==0.34.1" },
|
{ name = "rerun-sdk", specifier = "==0.34.1" },
|
||||||
{ name = "rich", specifier = ">=13.9,<15" },
|
{ name = "rich", specifier = ">=13.9,<15" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue