From 701aa837f62333ffd90e334bdc801e739bfdbb6a Mon Sep 17 00:00:00 2001 From: Silous Ramelli <204268110+TheRoboMaster123@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:42:15 -0700 Subject: [PATCH 1/5] docs(spec): protocol v0.3, resources, typed references, operator grants The spec for the three things v0.2 could not express, from the approved design. Sections 10 through 17 renumbered to 11 through 18 to make room for a real section 10, with every cross-reference in the repository bumped. Resources (new section 10, declaration in 7.6): URI-identified, typed, readable instrument state, declared in the descriptor beside commands so discovery is not a step an agent can skip. One read method. Child URIs compose by one protocol-defined rule from the read result's index, which is what keeps addressing out of per-instrument convention. Revisions are opaque and change with content; notification reuses the event channel under a reserved name. Content schemas carry a scoped unit keyword so a units-mandatory protocol does not ship a units-optional state format. Typed references (7.2): a resource_ref keyword on the schema node itself, with kind and enumerated_by both required, pattern forbidden on the same node, closure checked before a descriptor is ever served, and resolution at submission against a fresh read. The error names the pointer, the expected kind, the longest prefix that did resolve, and hands back a ready-to-send read request. Operator grants (8.6): S3 now takes something an agent structurally cannot produce. Grants live in a server-side store the protocol has no method to write, bound to a command name and the RFC 8785 digest of the normalized parameters (a binding adopted from LAP with credit), expiring and use-limited, consumed atomically. A refusal records a pending request so the operator's approval tool reads the parameters from the server's own store, never from a digest relayed through the agent that wants the approval. The manifest records an authorization block with a required identity_verified: false, turning the honesty caveat into a machine-checkable wire fact. Also per the approved decisions: if_revision optimistic concurrency with stale_revision (-32012), checked before authorization so a stale plan never costs an operator approval and never part of the digest so a re-read cannot invalidate a grant; normalized parameters as the single object that is validated, executed, digested, and recorded, closing the latent v0.2 bug where a manifest described something other than what ran; submission precedence reordered so everything knowable without an operator is checked first; and Appendix A, the kind registry, labelled honestly as seeded from one domain. Message models and error classes for every new shape land with the spec because the examples are machine-validated in CI and models are the machine-checkable form of the spec. Server behavior is V2; PROTOCOL_VERSION stays "0.2" until the enforcement that would make "0.3" true exists, and the capability flags advertise resources and grants as false for exactly that reason. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- ROADMAP.md | 4 +- packages/cli/src/labwire/cli/main.py | 2 +- packages/core/src/labwire/core/__init__.py | 18 + .../core/src/labwire/core/capabilities.py | 29 +- packages/core/src/labwire/core/client.py | 4 +- packages/core/src/labwire/core/errors.py | 29 +- packages/core/src/labwire/core/jcs.py | 2 +- packages/core/src/labwire/core/messages.py | 71 +- packages/core/src/labwire/core/server.py | 14 +- packages/core/src/labwire/core/session.py | 2 +- packages/core/src/labwire/core/signing.py | 20 +- packages/core/src/labwire/core/types.py | 2 +- packages/core/tests/test_jcs.py | 2 +- packages/core/tests/test_manifest_bundle.py | 2 +- packages/core/tests/test_messages.py | 1 + packages/core/tests/test_server_protocol.py | 10 +- packages/core/tests/test_signing.py | 2 +- packages/core/tests/test_spec_examples.py | 6 +- packages/core/tests/test_units_and_safety.py | 2 +- spec/SPEC.md | 1059 ++++++++++++++--- 23 files changed, 1099 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3971408..a365f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,7 +152,7 @@ Physical typing and safety classification, adopted from - `confirmation` proves deployment policy, not operator identity. LAP-style cryptographic operator binding is a roadmap item; do not treat v0.2 - confirmation as an audit control (SPEC §13). + confirmation as an audit control (SPEC §14). - Unit codes are validated for presence, not UCUM grammar. ## 0.1.0, 2026-07-23 diff --git a/CLAUDE.md b/CLAUDE.md index d0ce9cd..5d9737c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Work M0→M7 strictly in order. Per milestone: **one conventional commit** `confirmation` on submit. Recovery paths (clearing an interlock, e-stop) are S0 so they stay submittable while interlocked. - The UCUM discipline and the S0-S3 taxonomy come from LAP - (arXiv:2606.03755) and MUST keep their credit in SPEC §16 and PRIOR_ART.md. + (arXiv:2606.03755) and MUST keep their credit in SPEC §17 and PRIOR_ART.md. - Comparisons to other protocols stay factual and never disparaging; LAP in particular gets treated with respect. Never claim LAP compatibility or endorsement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4aa7fad..0a55692 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ uv manages Python itself; no system Python needed. applicable. - `make check` must be green; CI runs it on Python 3.12 and 3.13. - Protocol-affecting changes must update spec, models, and the conformance - table (§14) together. + table (§15) together. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 1878855..3891172 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,13 +7,13 @@ with tests. Anything here that Labwire does not do today is stated as missing in [README.md](README.md), [PRIOR_ART.md](PRIOR_ART.md), or the specification's -conformance table (SPEC §14.2) rather than implied to exist. +conformance table (SPEC §15.2) rather than implied to exist. ## Safety and accountability - **Cryptographic operator binding for `S2`/`S3` commands.** Today's `confirmation` is a deployment token: it proves policy, not identity - (SPEC §13). The intended successor is an operator token signed over the + (SPEC §14). The intended successor is an operator token signed over the task and the hash of its canonical parameters, as [LAP](https://arxiv.org/abs/2606.03755) specifies. This is the single most important gap in the current safety story. diff --git a/packages/cli/src/labwire/cli/main.py b/packages/cli/src/labwire/cli/main.py index a8e9961..70920b8 100644 --- a/packages/cli/src/labwire/cli/main.py +++ b/packages/cli/src/labwire/cli/main.py @@ -29,7 +29,7 @@ def verify(bundle: Path) -> None: Checks the ed25519 signature over the JCS-canonicalized manifest, the signer key_id, and, when records.jsonl is present, recomputes the - record-stream digest (SPEC §12.2). Exits 0 when authentic, 1 otherwise. + record-stream digest (SPEC §13.2). Exits 0 when authentic, 1 otherwise. """ outcome = verify_bundle(bundle) for warning in outcome.warnings: diff --git a/packages/core/src/labwire/core/__init__.py b/packages/core/src/labwire/core/__init__.py index fe137c4..01f64d6 100644 --- a/packages/core/src/labwire/core/__init__.py +++ b/packages/core/src/labwire/core/__init__.py @@ -17,6 +17,7 @@ IdentityInfo, InstrumentDescriptor, InterlockSpec, + ResourceSpec, SafetyClass, ) from labwire.core.client import ( @@ -27,6 +28,7 @@ TelemetrySubscription, ) from labwire.core.errors import ( + AuthorizationRequiredError, BusyError, CanceledError, ConfirmationRequiredError, @@ -39,6 +41,8 @@ LabwireError, MethodNotFoundError, NotCancelableError, + StaleRevisionError, + UnknownReferenceError, UnsupportedError, ValidationError, error_from_wire, @@ -46,12 +50,17 @@ from labwire.core.jcs import jcs_canonical, jcs_dumps from labwire.core.messages import ( MESSAGE_TYPES, + Authorization, CommandState, CommandStatus, EventNotification, EventSeverity, PeerInfo, Progress, + ResourceChangedData, + ResourceIndexEntry, + ResourceReadResult, + ResourceRevision, ServerCapabilities, ) from labwire.core.server import ( @@ -84,6 +93,8 @@ "MANIFEST_VERSION", "MESSAGE_TYPES", "PROTOCOL_VERSION", + "Authorization", + "AuthorizationRequiredError", "BusyError", "CanceledError", "ChannelSpec", @@ -118,17 +129,24 @@ "NotCancelableError", "PeerInfo", "Progress", + "ResourceChangedData", + "ResourceIndexEntry", + "ResourceReadResult", + "ResourceRevision", + "ResourceSpec", "RunRecord", "SafetyClass", "ServerCapabilities", "SessionClosed", "SigningKey", + "StaleRevisionError", "SystemClock", "TelemetryChannel", "TelemetrySample", "TelemetrySubscription", "Transport", "TransportClosed", + "UnknownReferenceError", "UnsupportedError", "ValidationError", "VerificationResult", diff --git a/packages/core/src/labwire/core/capabilities.py b/packages/core/src/labwire/core/capabilities.py index 407115b..2fa27ca 100644 --- a/packages/core/src/labwire/core/capabilities.py +++ b/packages/core/src/labwire/core/capabilities.py @@ -317,7 +317,7 @@ class _SpecModel(BaseModel): class IdentityInfo(_SpecModel): - """Instrument identity (SPEC §7.1); embedded verbatim in manifests (§12). + """Instrument identity (SPEC §7.1); embedded verbatim in manifests (§13). Example: >>> IdentityInfo( @@ -464,6 +464,31 @@ def _require_unit_code(self) -> Self: return self +class ResourceSpec(_SpecModel): + """A declared resource (SPEC §7.6): addressable, typed, readable state. + + Field-shape validation only in this milestone; the content-schema unit + rule and reference-closure checks land with the server implementation. + + Example: + >>> ResourceSpec( + ... uri="labwire:syringe", kind="consumable", title="Syringe", + ... description="The installed syringe.", item_kinds=[], + ... revision="r-1", content_schema={"type": "object", + ... "additionalProperties": False}, + ... ).kind + 'consumable' + """ + + uri: str + kind: str + title: str + description: str + item_kinds: list[str] + revision: str + content_schema: dict[str, Any] + + class InterlockSpec(_SpecModel): """A declared safety interlock (SPEC §7.4). @@ -498,4 +523,6 @@ class InstrumentDescriptor(_SpecModel): commands: list[CommandSpec] channels: list[ChannelSpec] interlocks: list[InterlockSpec] + resources: list[ResourceSpec] = [] + """REQUIRED of v0.3 servers (SPEC §7.1); tolerated absent on receipt.""" max_concurrent_commands: int = 1 diff --git a/packages/core/src/labwire/core/client.py b/packages/core/src/labwire/core/client.py index f957dd7..f294bfb 100644 --- a/packages/core/src/labwire/core/client.py +++ b/packages/core/src/labwire/core/client.py @@ -187,7 +187,7 @@ async def __anext__(self) -> TelemetrySample: class EventStream: - """Async iterator over pushed instrument events (SPEC §10). + """Async iterator over pushed instrument events (SPEC §11). Registered at creation time, so no events are missed between creating the stream and first iterating it. Call :meth:`close` (or exit the @@ -366,7 +366,7 @@ def telemetry( return TelemetrySubscription(self, channels, max_rate_hz) def events(self) -> EventStream: - """Stream instrument events as they arrive (SPEC §10). + """Stream instrument events as they arrive (SPEC §11). Example: >>> # async with client.events() as events: diff --git a/packages/core/src/labwire/core/errors.py b/packages/core/src/labwire/core/errors.py index 67d1587..e879f93 100644 --- a/packages/core/src/labwire/core/errors.py +++ b/packages/core/src/labwire/core/errors.py @@ -1,4 +1,4 @@ -"""Labwire domain errors (SPEC §11). +"""Labwire domain errors (SPEC §12). Every error carries a JSON-RPC code, a category string, and a ``retryable`` flag agents key retry policy off. @@ -46,7 +46,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._registry.setdefault(cls.code, cls) def to_wire(self) -> JsonRpcError: - """Serialize to the SPEC §11.2 error object. + """Serialize to the SPEC §12.2 error object. Example: >>> BusyError("full").to_wire().data.category @@ -134,12 +134,33 @@ class InternalError(LabwireError): class ConfirmationRequiredError(LabwireError): - """An S2/S3 command was submitted without an acceptable confirmation.""" + """An S2 command was submitted without an acceptable confirmation.""" code = -32009 category = "confirmation_required" +class UnknownReferenceError(LabwireError): + """A resource_ref value, or a resource/read URI, does not resolve (SPEC §10.4).""" + + code = -32010 + category = "unknown_reference" + + +class AuthorizationRequiredError(LabwireError): + """An S3 command was submitted without a verifiable operator grant (SPEC §8.6).""" + + code = -32011 + category = "authorization_required" + + +class StaleRevisionError(LabwireError): + """An if_revision precondition did not match the current revision (SPEC §10.5).""" + + code = -32012 + category = "stale_revision" + + class InvalidRequestError(LabwireError): """JSON-RPC -32600: structurally invalid request (e.g. duplicate initialize).""" @@ -164,7 +185,7 @@ class InvalidParamsError(LabwireError): def error_from_wire(wire: JsonRpcError) -> LabwireError: """Reconstruct the typed error for a wire error object. - Unknown codes fall back to :class:`LabwireError`; per SPEC §11.2, errors + Unknown codes fall back to :class:`LabwireError`; per SPEC §12.2, errors lacking ``data.retryable`` are treated as not retryable. Example: diff --git a/packages/core/src/labwire/core/jcs.py b/packages/core/src/labwire/core/jcs.py index 04302ec..4b74318 100644 --- a/packages/core/src/labwire/core/jcs.py +++ b/packages/core/src/labwire/core/jcs.py @@ -1,4 +1,4 @@ -"""RFC 8785 (JCS) JSON canonicalization (SPEC §12.2). +"""RFC 8785 (JCS) JSON canonicalization (SPEC §13.2). Vendored implementation (the algorithm is small and dependency-free): keys sort by code point, strings use minimal escaping with literal UTF-8, and diff --git a/packages/core/src/labwire/core/messages.py b/packages/core/src/labwire/core/messages.py index 55cfcc6..a0a28cd 100644 --- a/packages/core/src/labwire/core/messages.py +++ b/packages/core/src/labwire/core/messages.py @@ -1,4 +1,4 @@ -"""Protocol message params/result models and the method registry (SPEC §15). +"""Protocol message params/result models and the method registry (SPEC §16). ``MESSAGE_TYPES`` maps every protocol method name to its params model and (for requests) result model. The registry is what validates SPEC.md's @@ -45,6 +45,8 @@ class ServerCapabilities(_Msg): telemetry: bool = False events: bool = False manifests: bool = False + resources: bool = False + grants: bool = False class InitializeParams(_Msg): @@ -72,12 +74,25 @@ class EmptyResult(_Msg): """Empty result object.""" +class Authorization(_Msg): + """An operator grant presented on an S3 submission (SPEC §8.6). + + An object rather than a bare string so a future cryptographic scheme + can add members without changing the field's type. + """ + + grant_id: str + + class SubmitParams(_Msg): """Params of ``command/submit`` (SPEC §8.2).""" command: str params: dict[str, Any] confirmation: str | None = None + authorization: Authorization | None = None + if_revision: dict[str, str] | None = None + """Maps resource URI to the revision the client planned against (SPEC §10.5).""" class SubmitResult(_Msg): @@ -94,6 +109,13 @@ class Progress(_Msg): message: str | None = None +class ResourceRevision(_Msg): + """One resource's revision after a run changed it (SPEC §8.2).""" + + uri: str + revision: str + + class CommandStatus(_Msg): """The CommandStatus object (SPEC §8.2): pushed and polled alike.""" @@ -102,6 +124,7 @@ class CommandStatus(_Msg): progress: Progress | None = None result: Any = None error: JsonRpcError | None = None + resource_revisions: list[ResourceRevision] | None = None class CommandIdParams(_Msg): @@ -140,7 +163,7 @@ class TelemetryNotification(_Msg): class EventNotification(_Msg): - """Params of ``notifications/event`` (SPEC §10).""" + """Params of ``notifications/event`` (SPEC §11).""" name: str timestamp: str @@ -148,6 +171,47 @@ class EventNotification(_Msg): data: dict[str, Any] +class ResourceReadParams(_Msg): + """Params of ``resource/read`` (SPEC §10.2).""" + + uri: str + + +class ResourceIndexChildren(_Msg): + """The items of one index entry: ids composing ``/`` (SPEC §10.2).""" + + kinds: list[str] + ids: list[str] + + +class ResourceIndexEntry(_Msg): + """One entry of a resource's reference index (SPEC §10.2).""" + + uri: str + kinds: list[str] + title: str | None = None + children: ResourceIndexChildren | None = None + + +class ResourceReadResult(_Msg): + """Result of ``resource/read`` (SPEC §10.2).""" + + uri: str + kind: str + revision: str + read_at: str + index_complete: bool + index: list[ResourceIndexEntry] + content: Any = None + + +class ResourceChangedData(_Msg): + """``data`` of the reserved ``resource/changed`` event (SPEC §10.3).""" + + uri: str + revision: str + + class MessageTypes(NamedTuple): """Registry entry: params model, and result model for requests.""" @@ -159,6 +223,7 @@ class MessageTypes(NamedTuple): "initialize": MessageTypes(InitializeParams, InitializeResult), "ping": MessageTypes(EmptyParams, EmptyResult), "instrument/describe": MessageTypes(EmptyParams, InstrumentDescriptor), + "resource/read": MessageTypes(ResourceReadParams, ResourceReadResult), "command/submit": MessageTypes(SubmitParams, SubmitResult), "command/status": MessageTypes(CommandIdParams, CommandStatus), "command/cancel": MessageTypes(CommandIdParams, CommandStatus), @@ -169,4 +234,4 @@ class MessageTypes(NamedTuple): "notifications/telemetry": MessageTypes(TelemetryNotification, None), "notifications/event": MessageTypes(EventNotification, None), } -"""Every protocol method (SPEC §15), for validation and schema export.""" +"""Every protocol method (SPEC §16), for validation and schema export.""" diff --git a/packages/core/src/labwire/core/server.py b/packages/core/src/labwire/core/server.py index 8984953..3cc1403 100644 --- a/packages/core/src/labwire/core/server.py +++ b/packages/core/src/labwire/core/server.py @@ -325,7 +325,7 @@ async def progress(self, fraction: float | None = None, message: str | None = No def emit_event( self, name: str, severity: EventSeverity = "info", data: dict[str, Any] | None = None ) -> None: - """Emit a protocol event to every operational session (SPEC §10).""" + """Emit a protocol event to every operational session (SPEC §11).""" self._emit_event(name, severity, data or {}) def now(self) -> datetime: @@ -547,7 +547,7 @@ def _canonical(record: dict[str, Any]) -> bytes: def _pydantic_error_details(exc: PydanticValidationError) -> list[dict[str, str]]: - # Structured, agent-actionable detail (SPEC §11.2): field, message, type. + # Structured, agent-actionable detail (SPEC §12.2): field, message, type. # Field names are not internal paths; tracebacks are never included. return [ { @@ -777,7 +777,7 @@ def spawn(self, coro: Awaitable[None]) -> None: def emit_event( self, name: str, severity: EventSeverity = "info", data: dict[str, Any] | None = None ) -> None: - """Emit a protocol event outside any command (SPEC §10). + """Emit a protocol event outside any command (SPEC §11). Example: >>> # server.emit_event("instrument/state_changed", "info", {"state": "idle"}) @@ -880,7 +880,7 @@ async def _initialize(self, session: _ServerSession, params: dict[str, Any]) -> return result.model_dump(mode="json") def _validate[M: BaseModel](self, model: type[M], params: dict[str, Any]) -> M: - # Method-shape params (SPEC §11.1: -32602); the command's own + # Method-shape params (SPEC §12.1: -32602); the command's own # params_schema violations are -32000 and handled in _submit. try: return model.model_validate(params) @@ -896,7 +896,7 @@ def _active_runs(self) -> list[_Run]: return [run for run in self._runs.values() if run.active] async def _submit(self, session: _ServerSession, params: dict[str, Any]) -> dict[str, Any]: - # Rejection precedence per SPEC §11.1: unsupported → validation → + # Rejection precedence per SPEC §12.1: unsupported → validation → # interlock → capacity busy. submit = self._validate(SubmitParams, params) meta = self.instrument.commands().get(submit.command) @@ -951,7 +951,7 @@ def _confirmed(self, confirmation: str | None) -> bool: Deployment policy: with a token configured the value must match it; without one, any non-empty value is accepted. Either way this proves - deployment policy, not operator identity, see SPEC §13. + deployment policy, not operator identity, see SPEC §14. """ if confirmation is None or not confirmation.strip(): return False @@ -1068,7 +1068,7 @@ async def _report_progress( run.session.notify_soon("notifications/command_status", run.snapshot()) def _get_run(self, params: dict[str, Any]) -> _Run: - # -32602 for a malformed/missing command_id (method shape, SPEC §11.1); + # -32602 for a malformed/missing command_id (method shape, SPEC §12.1); # -32000 for a well-formed but unknown one (unknown entity). parsed = self._validate(CommandIdParams, params) run = self._runs.get(parsed.command_id) diff --git a/packages/core/src/labwire/core/session.py b/packages/core/src/labwire/core/session.py index 409da7e..c073d42 100644 --- a/packages/core/src/labwire/core/session.py +++ b/packages/core/src/labwire/core/session.py @@ -174,7 +174,7 @@ def _dispatch(self, raw: dict[str, Any]) -> None: try: message = parse_message(raw) except Exception as exc: - # Invalid envelope: answer -32600 (SPEC §11.1). Echo the id when + # Invalid envelope: answer -32600 (SPEC §12.1). Echo the id when # it is a valid integer; JSON-RPC prescribes id null otherwise. if "id" in raw: raw_id = raw.get("id") diff --git a/packages/core/src/labwire/core/signing.py b/packages/core/src/labwire/core/signing.py index a4efde4..2db42b6 100644 --- a/packages/core/src/labwire/core/signing.py +++ b/packages/core/src/labwire/core/signing.py @@ -1,4 +1,4 @@ -"""ed25519 run-manifest signing and verification (SPEC §12). +"""ed25519 run-manifest signing and verification (SPEC §13). The signature covers the RFC 8785 canonicalization of the manifest minus its ``signature`` field, and is encoded as unpadded base64url. @@ -78,12 +78,12 @@ def save(self, path: Path) -> None: @property def public_key_b64(self) -> str: - """The 32-byte public key, standard base64 (SPEC §12.1).""" + """The 32-byte public key, standard base64 (SPEC §13.1).""" return base64.b64encode(bytes(self._raw.verify_key)).decode() @property def key_id(self) -> str: - """``sha256:`` + hex SHA-256 of the raw public key (SPEC §12.1).""" + """``sha256:`` + hex SHA-256 of the raw public key (SPEC §13.1).""" return "sha256:" + hashlib.sha256(bytes(self._raw.verify_key)).hexdigest() def sign(self, message: bytes) -> bytes: @@ -96,7 +96,7 @@ class _M(BaseModel): class ManifestCommand(_M): - """The submitted command, verbatim, and its enforced class (SPEC §12.1).""" + """The submitted command, verbatim, and its enforced class (SPEC §13.1).""" name: str params: dict[str, Any] @@ -104,7 +104,7 @@ class ManifestCommand(_M): class ManifestData(_M): - """Digest of the run's record stream (SPEC §12.1).""" + """Digest of the run's record stream (SPEC §13.1).""" digest_alg: str digest: str @@ -112,7 +112,7 @@ class ManifestData(_M): class ManifestTimestamps(_M): - """RFC 3339 UTC run timestamps (SPEC §12.1).""" + """RFC 3339 UTC run timestamps (SPEC §13.1).""" submitted: str started: str @@ -120,7 +120,7 @@ class ManifestTimestamps(_M): class SignerInfo(_M): - """Key identification for the manifest signature (SPEC §12.1).""" + """Key identification for the manifest signature (SPEC §13.1).""" alg: str public_key: str @@ -128,7 +128,7 @@ class SignerInfo(_M): class Manifest(_M): - """The SPEC §12.1 run manifest document (optionally signed). + """The SPEC §13.1 run manifest document (optionally signed). Example: >>> # Manifest.model_validate(json.loads(bundle_manifest_json)) @@ -174,7 +174,7 @@ def sign_manifest(manifest: dict[str, Any], key: SigningKey) -> dict[str, Any]: def verify_manifest(doc: dict[str, Any]) -> VerificationResult: - """Verify a signed manifest's key_id and signature (SPEC §12.2). + """Verify a signed manifest's key_id and signature (SPEC §13.2). Example: >>> # outcome = verify_manifest(json.loads(manifest_json)) @@ -229,7 +229,7 @@ def verify_bundle(path: Path) -> VerificationResult: try: parsed = Manifest.model_validate(doc) except Exception as exc: - errors.append(f"manifest does not match SPEC §12.1: {exc}") + errors.append(f"manifest does not match SPEC §13.1: {exc}") return VerificationResult(ok=False, errors=errors) records_path = manifest_path.parent / "records.jsonl" if records_path.exists(): diff --git a/packages/core/src/labwire/core/types.py b/packages/core/src/labwire/core/types.py index a96eadc..f25a6bb 100644 --- a/packages/core/src/labwire/core/types.py +++ b/packages/core/src/labwire/core/types.py @@ -61,7 +61,7 @@ class JsonRpcResponse(_Envelope): class ErrorData(BaseModel): - """The ``data`` member of a Labwire error (SPEC §11.2). + """The ``data`` member of a Labwire error (SPEC §12.2). Example: >>> ErrorData(category="busy", retryable=True).category diff --git a/packages/core/tests/test_jcs.py b/packages/core/tests/test_jcs.py index 9182107..1c317bf 100644 --- a/packages/core/tests/test_jcs.py +++ b/packages/core/tests/test_jcs.py @@ -1,4 +1,4 @@ -"""Tests for RFC 8785 (JCS) canonicalization (SPEC §12.2).""" +"""Tests for RFC 8785 (JCS) canonicalization (SPEC §13.2).""" import math diff --git a/packages/core/tests/test_manifest_bundle.py b/packages/core/tests/test_manifest_bundle.py index ff1ae1d..f74ba57 100644 --- a/packages/core/tests/test_manifest_bundle.py +++ b/packages/core/tests/test_manifest_bundle.py @@ -1,4 +1,4 @@ -"""End-to-end: signed bundles written per terminal run (SPEC §12).""" +"""End-to-end: signed bundles written per terminal run (SPEC §13).""" import asyncio import hashlib diff --git a/packages/core/tests/test_messages.py b/packages/core/tests/test_messages.py index ed9efbb..ab40745 100644 --- a/packages/core/tests/test_messages.py +++ b/packages/core/tests/test_messages.py @@ -20,6 +20,7 @@ def test_registry_covers_every_spec_method() -> None: "initialize", "ping", "instrument/describe", + "resource/read", "command/submit", "command/status", "command/cancel", diff --git a/packages/core/tests/test_server_protocol.py b/packages/core/tests/test_server_protocol.py index 64e4e93..a856ed0 100644 --- a/packages/core/tests/test_server_protocol.py +++ b/packages/core/tests/test_server_protocol.py @@ -160,7 +160,15 @@ async def test_initialize_result_and_gating() -> None: }, ) assert result["protocol_version"] == "0.2" - assert result["capabilities"] == {"telemetry": True, "events": True, "manifests": False} + # resources and grants advertise False until the server implements + # them (SPEC §6.1): a not-yet-implementing server must say so. + assert result["capabilities"] == { + "telemetry": True, + "events": True, + "manifests": False, + "resources": False, + "grants": False, + } await session.notify("notifications/initialized", {}) desc = await session.request("instrument/describe", {}) assert desc["identity"]["model"] == "TestRig-1" diff --git a/packages/core/tests/test_signing.py b/packages/core/tests/test_signing.py index 81274ba..791f2ce 100644 --- a/packages/core/tests/test_signing.py +++ b/packages/core/tests/test_signing.py @@ -1,4 +1,4 @@ -"""Tests for ed25519 manifest signing and verification (SPEC §12).""" +"""Tests for ed25519 manifest signing and verification (SPEC §13).""" import base64 import hashlib diff --git a/packages/core/tests/test_spec_examples.py b/packages/core/tests/test_spec_examples.py index ab216e8..502a4a4 100644 --- a/packages/core/tests/test_spec_examples.py +++ b/packages/core/tests/test_spec_examples.py @@ -1,6 +1,6 @@ """Round-trip every JSON example in spec/SPEC.md through the message models. -SPEC §15: from milestone M2, every marked fenced JSON block must round-trip +SPEC §16: from milestone M2, every marked fenced JSON block must round-trip through the model registered for its method. Manifest examples validate from M4 and are skipped here; ``signature-excerpt`` blocks are exempt. """ @@ -44,7 +44,7 @@ def test_spec_example_round_trips(marker: str, raw: dict[str, Any]) -> None: name, kind = _split(marker) if name == "manifest": if kind == "signature-excerpt": - # excerpt blocks are validated only for the fields present (SPEC §15) + # excerpt blocks are validated only for the fields present (SPEC §16) assert "signature" in raw assert set(raw) <= set(Manifest.model_fields), "unknown field in excerpt" return @@ -76,4 +76,4 @@ def test_every_registry_method_has_at_least_one_example() -> None: exampled = {_split(marker)[0] for marker, _ in _examples()} missing = {m for m in MESSAGE_TYPES if m not in exampled} # notifications/command_status has two examples; every method needs >= 1 - assert not missing, f"spec §15 lacks examples for: {sorted(missing)}" + assert not missing, f"spec §16 lacks examples for: {sorted(missing)}" diff --git a/packages/core/tests/test_units_and_safety.py b/packages/core/tests/test_units_and_safety.py index b617576..5c04752 100644 --- a/packages/core/tests/test_units_and_safety.py +++ b/packages/core/tests/test_units_and_safety.py @@ -325,7 +325,7 @@ async def test_blank_confirmation_is_not_a_confirmation() -> None: async def test_validation_precedes_confirmation( rig: tuple[SafetyRig, InstrumentServer, LabwireClient], ) -> None: - """An unconfirmable request that could never run fails as validation (SPEC §11.1).""" + """An unconfirmable request that could never run fails as validation (SPEC §12.1).""" from labwire.core import ValidationError with pytest.raises(ValidationError): diff --git a/spec/SPEC.md b/spec/SPEC.md index c134f8b..cdc4755 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -1,7 +1,7 @@ # Labwire Protocol Specification -**Version:** 0.2.1 (Draft) -**Protocol version string:** `"0.2"` +**Version:** 0.3.0 (Draft) +**Protocol version string:** `"0.3"` **Date:** 2026-07-27 **License:** Apache-2.0 @@ -14,12 +14,18 @@ agents, both human-operated software and autonomous AI systems, a universal way to **discover** an instrument's capabilities, **command** it, **stream** its measurements, and receive **cryptographically signed** records of what was done. The protocol is JSON-RPC 2.0 over WebSocket or stdio, with a capability -discovery model inspired by the Model Context Protocol (MCP). +discovery model inspired by the Model Context Protocol (MCP). Version 0.3 +adds three things v0.2 could not express: **resources** (addressable, typed, +readable instrument state, such as a liquid handler's deck), **typed +references** (parameters that name a resource item rather than carrying an +uninterpreted string), and **operator grants** (an S3 authorization an agent +structurally cannot produce, bound to a command and to a digest of its exact +parameters). This document is a **draft**. It is developed alongside a working reference -implementation; §14.2 states exactly which parts of this specification that +implementation; §15.2 states exactly which parts of this specification that implementation realizes. Breaking changes are expected before 1.0, and v0.2 -already makes some (§17). +already makes some (§18). ## 2. Terminology & Conformance @@ -40,10 +46,14 @@ when, and only when, they appear in all capitals, as shown here. instrument's descriptor (§7) and executed via the command lifecycle (§8). - **Run:** a single execution of a command, identified by a `command_id`. - **Channel:** a typed, named stream of measurements (§7, §9). -- **Event:** a discrete occurrence reported by the server (§10). +- **Event:** a discrete occurrence reported by the server (§11). - **Interlock:** a declared safety condition which, while tripped, prevents - command execution (§7, §8, §10). -- **Run manifest:** a signed record of a completed run (§12). + command execution (§7, §8, §11). +- **Resource:** a named, URI-identified piece of instrument state, declared + in the descriptor and read with `resource/read` (§7.6, §10). +- **Operator grant:** an out-of-band-provisioned authorization for one S3 + command with one exact parameter set (§8.6). +- **Run manifest:** a signed record of a completed run (§13). All JSON field names defined by this protocol use `snake_case`. Unless otherwise stated, unrecognized fields MUST be ignored by both parties @@ -68,6 +78,9 @@ Agent Client Instrument Server │ instrument/describe ──────────────────────────▶│ │◀──────────────── result: InstrumentDescriptor │ │ │ + │ resource/read {uri} ──────────────────────────▶│ + │◀──────────────── result: {revision, index, ...} │ + │ │ │ command/submit {command, params} ─────────────▶│ │◀──────────────── result: {command_id, accepted} │ │◀─────────── notifications/command_status (push) │ @@ -79,8 +92,9 @@ Agent Client Instrument Server │ │ ``` -A typical agent session: initialize → describe → submit a command → watch -pushed status until a terminal state → read telemetry → repeat → disconnect. +A typical agent session: initialize → describe → read the resources the +descriptor declares → submit a command → watch pushed status until a terminal +state → read telemetry → repeat → disconnect. ### 3.1 JSON-RPC usage @@ -94,7 +108,7 @@ pushed status until a terminal state → read telemetry → repeat → disconnec ## 4. Versioning & Negotiation The protocol version is a string of the form `"MAJOR.MINOR"`. This document -specifies protocol version `"0.2"`. +specifies protocol version `"0.3"`. - The client states its protocol version in `initialize`. - The server replies with the protocol version **it will speak**: the highest @@ -103,10 +117,10 @@ specifies protocol version `"0.2"`. MUST close the connection. - Servers SHOULD accept any client version that shares their MAJOR version. For MAJOR version 0, the MINOR version carries compatibility significance: - servers SHOULD reply with exactly `"0.2"` if they implement this document. + servers SHOULD reply with exactly `"0.3"` if they implement this document. The specification document itself is versioned `MAJOR.MINOR.PATCH` -(this document: 0.2.1); PATCH revisions never change the wire protocol. +(this document: 0.3.0); PATCH revisions never change the wire protocol. ## 5. Transports @@ -123,7 +137,7 @@ transport's job, and exactly one JSON-RPC message occupies one frame. - WebSocket protocol-level ping/pong frames MAY be used for keepalive by either party. - Servers MAY serve plaintext `ws://` on loopback or isolated lab networks; - deployments crossing any network boundary SHOULD use `wss://` (see §13). + deployments crossing any network boundary SHOULD use `wss://` (see §14). - Port 9520 is the RECOMMENDED default port. This is a convention, not a requirement. @@ -148,7 +162,7 @@ The first message in a session MUST be an `initialize` request from the client. **Initialization completes when the server receives `notifications/initialized`.** Requests other than `ping` received before that point MUST be rejected with error `-32002` (`busy`), with -`retryable: false` (§11.1). An `initialize` request received after +`retryable: false` (§12.1). An `initialize` request received after initialization has completed MUST be rejected with `-32600` (invalid request). @@ -159,7 +173,7 @@ request). software. - `capabilities` (object, REQUIRED): reserved for client capability flags; MAY be empty in v0.2. -- `api_key` (string, OPTIONAL): see §13. +- `api_key` (string, OPTIONAL): see §14. `initialize` result: @@ -168,10 +182,19 @@ request). - `server_info` (object, REQUIRED): `{name, version}` identifying the server software. - `capabilities` (object, REQUIRED): server capability flags. Defined in - v0.2: `telemetry` (boolean), `events` (boolean), and `manifests` - (boolean: the server produces signed run manifests, §12). Absent flags - default to `false`. A request for a method belonging to a capability the server - advertised as `false` MUST be rejected with `-32001` (`unsupported`). + v0.3: `telemetry` (boolean), `events` (boolean), `manifests` + (boolean: the server produces signed run manifests, §13), `resources` + (boolean: the server answers `resource/read`, §10), and `grants` + (boolean: the server holds an operator grant store, §8.6). Absent flags + default to `false`. A request for a method belonging to a capability the + server advertised as `false` MUST be rejected with `-32001` + (`unsupported`). + + A server that declares any `S3` command and advertises `grants: false` is + **non-conforming and MUST refuse to start**: a server with hazardous + commands and no way to authorize them is misconfigured, not permissive. + Likewise a server whose commands carry `resource_ref` declarations (§7.2) + MUST advertise `resources: true`. After receiving the result, the client MUST send the `notifications/initialized` notification before any other message. The @@ -202,7 +225,7 @@ A server MAY accept multiple simultaneous sessions. Session-scoped rules: that submitted it. `command/status` polling MUST work from any session that presents the `command_id`. - Telemetry notifications are delivered only to the subscribing session. -- Events (§10) are delivered to every operational session. +- Events (§11) are delivered to every operational session. - `max_concurrent_commands` (§8.4) is a per-instrument limit shared across all sessions. @@ -223,10 +246,16 @@ without out-of-band knowledge. image, when known. Simulated instruments SHOULD hash their implementing code's version identity instead. - This identity object is embedded verbatim in run manifests (§12). + This identity object is embedded verbatim in run manifests (§13). - `commands` (array, REQUIRED): see §7.2. - `channels` (array, REQUIRED): see §7.3. - `interlocks` (array, REQUIRED): see §7.4. +- `resources` (array, REQUIRED): see §7.6. `[]` when the instrument exposes + no resources; an instrument with no tree-shaped state loses nothing by + saying so. There is deliberately no `resources/list` method: an + instrument's resources are as much a property of its kind as its commands + are, so they arrive inside `instrument/describe`, the request every client + already makes, and discovering them is not a step an agent can skip. - `max_concurrent_commands` (integer, OPTIONAL, default `1`): how many commands the instrument executes simultaneously (§8.4). @@ -250,6 +279,38 @@ Each entry in `commands`: NOT be used; commands that take no parameters declare `{"type": "object", "additionalProperties": false}`. The same requirement applies to `returns_schema`. + + **Typed references.** A string-typed schema node inside `params_schema` + MAY carry a `resource_ref` keyword declaring that its value names an item + of a resource rather than being an uninterpreted string: + + ```json + { + "type": "string", + "resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" } + } + ``` + + Both members are REQUIRED. `kind` is a registered or vendor-prefixed kind + name (Appendix A) matched against the resolved entry's `kinds` array + (§10.2). `enumerated_by` is the URI of a resource declared in this + descriptor whose `item_kinds` contains `kind`; a declaration violating + either condition is invalid, and servers MUST refuse to serve it. The + keyword rides *inside* the schema deliberately: `params_schema` is the + object that travels verbatim into agent tool schemas, so the pointer to + where valid values live reaches the agent at the exact parameter it cannot + fill, with no side table for an adapter to forget. Unknown keywords are + ignored by ordinary JSON Schema validators, so the schema stays legal + draft 2020-12. + + A node carrying `resource_ref` MUST NOT also declare a `pattern`: a + pattern is satisfiable by invention, which is precisely the failure typed + references exist to remove. `resource_ref` is permitted only inside + `params_schema`, not in `returns_schema` or channel declarations, in + v0.3. Reference values are validated against current resource state at + submission (§10.4); the semantics of that check, and the error a failure + produces, are protocol-defined so the reference vocabulary is shared by + every instrument rather than invented per bridge. - `unit_annotations` (object, REQUIRED): maps parameter name → **UCUM case-sensitive unit code** (e.g. `"mL/min"`, `"Cel"`, `"g"`, `"V"`). **Every parameter that carries a number MUST have an entry**, and @@ -351,9 +412,9 @@ Each entry in `interlocks`: - `tripped` (boolean, REQUIRED): whether the interlock is tripped at the time the `instrument/describe` response is produced. Consumers MUST treat this as a snapshot, kept current only via the `interlock/tripped` and - `interlock/cleared` events (§10). + `interlock/cleared` events (§11). -Interlock behavior is specified in §8.5 and §10. +Interlock behavior is specified in §8.5 and §11. ### 7.5 Vendor extensions @@ -363,6 +424,62 @@ exclusively for this form. Servers MAY expose them; clients MUST NOT assume their presence. Extension commands MUST still be declared in `commands` with full schemas. +### 7.6 Resource declaration + +A **resource** is addressable, typed, readable instrument state: the deck of +a liquid handler, the installed syringe of a pump. Commands describe what an +instrument can *do*; resources describe what *exists to do it to*. Each +entry in `resources`: + +- `uri` (string, REQUIRED): the resource's identifier, unique within the + instrument. See §10.1 for the scheme. +- `kind` (string, REQUIRED): what the resource is, from the registry + (Appendix A) or vendor-prefixed (`.`). +- `title` (string, REQUIRED): short human/agent-readable label. +- `description` (string, REQUIRED): what the resource contains, what its + index enumerates, and when it changes, in enough detail for an agent to + decide when to read it. Servers SHOULD state here which command + parameters draw their valid values from this resource's index. +- `item_kinds` (array of strings, REQUIRED): every kind that can appear in + this resource's index (§10.2), so the closure of `resource_ref` + declarations is checkable from the descriptor alone. `[]` for a resource + with no index. +- `revision` (string, REQUIRED): the revision at the time the descriptor + was produced, a snapshot exactly as `interlocks[].tripped` is (§10.3). +- `content_schema` (object, REQUIRED): a JSON Schema (draft 2020-12) object + describing the `content` member of a read result. The closed-schema + requirement of §7.2 applies unchanged. + + **Units inside content.** Every schema node in `content_schema` that + describes a `number` or `integer` MUST carry a `unit` keyword holding a + UCUM case-sensitive code (`"1"` for dimensionless): + + ```json + { "type": "number", "unit": "uL" } + ``` + + Resource content is state, state carries quantities, and shipping a + units-optional state format inside a units-mandatory protocol would + reopen the hole §7.2 closed, one surface over. The `unit` keyword is + scoped to `content_schema` in v0.3: it is NOT permitted in + `params_schema` or `returns_schema`, whose units remain declared in + `unit_annotations` and `returns_units`. Two annotation schemes exist, but + they apply to disjoint surfaces and neither is optional, so there is + never a question of which one to use. Unknown keywords are ignored by + ordinary JSON Schema validators, so the schema stays legal draft 2020-12. + The term `unit`, and the placement of semantics inside the data schema + rather than in a side table, follow W3C Web of Things Thing Description + practice (§17). + +Resources are **read-only** in v0.3. Anything that changes instrument state +remains a command, so every state change stays classed, confirmed, +recorded, and signed; `set_well_volume` remains a command. A server MUST +refuse to start if any `resource_ref` in its commands names a resource not +declared here, or a `kind` absent from that resource's `item_kinds`: the +graph from parameter to kind to enumerating resource is provably closed +before a descriptor is ever served. + ## 8. Command Lifecycle ### 8.1 States @@ -397,16 +514,32 @@ Legal transitions: `accepted → running | canceling | failed`; `command/submit` params: `command` (string, REQUIRED: a declared command name), `params` (object, REQUIRED, validated against the command's `params_schema`; MAY be `{}`), `confirmation` (string, OPTIONAL, required -for `S2`/`S3` commands, see §8.6). - -An undeclared `command` name MUST be rejected with `-32001` (`unsupported`). -If `params` violate the command's `params_schema`, the server MUST reject -the request with `-32000` (`validation`). If the command's `safety_class` is -`S2` or `S3` and no acceptable `confirmation` is supplied, the server MUST -reject it with `-32009` (`confirmation_required`) (§8.6). In all these cases -the server MUST NOT create a run. Otherwise the server assigns a -`command_id` (string, unique per instrument, RECOMMENDED: UUIDv4) and -responds `{command_id, status: "accepted"}`. +for `S2` commands, see §8.6), `authorization` (object, OPTIONAL, required +for `S3` commands, see §8.6: `{"grant_id": ""}`), and `if_revision` +(object, OPTIONAL: maps resource URI → the revision the client planned +against, see §10.5). + +Submission checks run in the precedence order of §12.1. An undeclared +`command` name MUST be rejected with `-32001` (`unsupported`); `params` +violating the command's `params_schema` with `-32000` (`validation`); a +reference value that does not resolve with `-32010` (`unknown_reference`, +§10.4); a stale `if_revision` with `-32012` (`stale_revision`, §10.5); a +missing or unacceptable `confirmation` or `authorization` with `-32009` +(`confirmation_required`) or `-32011` (`authorization_required`) (§8.6). +In all these cases the server MUST NOT create a run. Otherwise the server +assigns a `command_id` (string, unique per instrument, RECOMMENDED: UUIDv4) +and responds `{command_id, status: "accepted"}`. + +**Normalized parameters.** From validation onward the server MUST use the +post-validation parameter object, with schema defaults applied, as *the* +parameters of the run: it is what handlers receive, what the manifest +records as `command.params` (§13.1), and what the authorization digest is +computed over (§8.6). The digested thing and the recorded thing therefore +cannot disagree, and an auditor can recompute the digest offline from the +bundle. `confirmation`, `authorization`, and `if_revision` are envelope +fields, not parameters: they are never part of the normalized object or +the digest, so re-reading a resource after an operator approves a call +cannot invalidate the approval. **Status is push-first.** On every state transition out of `accepted`, the server MUST send `notifications/command_status` to the submitting session @@ -424,9 +557,15 @@ object: only `progress`. - `result` (any, OPTIONAL): present iff `status` is `succeeded`; conforms to the command's `returns_schema` if declared. -- `error` (object, OPTIONAL): an Error object (§11.2); present iff +- `error` (object, OPTIONAL): an Error object (§12.2); present iff `status` is `failed`, except that servers MAY additionally attach an error with category `canceled` (code `-32006`) to `canceled` runs. +- `resource_revisions` (array, OPTIONAL): on a **terminal** status, the + resources this run changed, as `[{uri, revision}]` with each resource's + revision after the run. This is the write-returns-the-new-revision + pattern of HTTP conditional requests (§17): an agent that submits every + change itself never needs to re-read a resource between steps, because + each terminal status hands it the revision to plan the next step against. `command/status` params `{command_id}` MUST return the current CommandStatus; unknown `command_id` → error `-32000` (`validation`). @@ -457,12 +596,12 @@ An instrument executes at most `max_concurrent_commands` (§7.1) runs simultaneously. While at capacity, the server MUST reject `command/submit` with error `-32002` (`busy`). The protocol defines **no server-side queueing**: queueing, retry, and scheduling are client (agent) policy. The -capacity `busy` error is retryable (§11.2); servers MAY include +capacity `busy` error is retryable (§12.2); servers MAY include `details.retry_after_s` (number) as a backoff hint. ### 8.5 Interlocks -While any declared interlock is **tripped** (§10): +While any declared interlock is **tripped** (§11): - New `command/submit` requests MUST be rejected with error `-32003` (`interlock`): except that a command whose `clears_interlocks` (§7.2) @@ -474,7 +613,7 @@ While any declared interlock is **tripped** (§10): the rule). The server MUST emit `interlock/tripped` and `interlock/cleared` events -(§10). How an interlock clears is instrument-defined and MUST be stated in +(§11). How an interlock clears is instrument-defined and MUST be stated in its `description` (e.g. a hard interlock clears only at the instrument; a soft interlock clears via a declared command). @@ -485,38 +624,98 @@ LAP ([arXiv:2606.03755](https://arxiv.org/abs/2606.03755); see [PRIOR_ART.md](../PRIOR_ART.md)) so that instruments and agents crossing between the two protocols classify actions the same way: -| Class | Meaning | Confirmation | +| Class | Meaning | Requires | |---|---|---| -| `S0` | Emergency or protective operations (stop, vent, clear). Always permitted. | never required | -| `S1` | Routine and reversible (read a value, set a setpoint). **Default.** | not required | -| `S2` | Costly or irreversible (consumes reagent, destroys a sample). | REQUIRED | -| `S3` | Hazardous, capable of harming people or equipment. | REQUIRED | - -Normative rules: - -- Servers MUST reject a `command/submit` for an `S2` or `S3` command that - carries no `confirmation` value, with error `-32009` - (`confirmation_required`), `retryable: false`, and - `data.details.safety_class` set to the command's class. Retrying the - identical request cannot succeed; the client must obtain confirmation. -- A server MUST NOT require confirmation for `S0`, and SHOULD NOT for `S1`. +| `S0` | Emergency or protective operations (stop, vent, clear). Always permitted. | nothing | +| `S1` | Routine and reversible (read a value, set a setpoint). **Default.** | nothing | +| `S2` | Costly or irreversible (consumes reagent, destroys a sample). | `confirmation` | +| `S3` | Hazardous, capable of harming people or equipment. | an **operator grant** | + +In v0.2 the two upper classes were gated by the same confirmation string, +so classifying a command `S3` changed what was printed and recorded and +nothing about what was permitted. v0.3 makes them different mechanisms: +`S2` takes a session confirmation an agent can hold; `S3` takes a grant an +agent structurally cannot produce. + +Normative rules common to both: + +- A server MUST NOT require confirmation or authorization for `S0`, and + SHOULD NOT for `S1`. - Servers MUST NOT downgrade a command's declared class at submission time. - `S0` commands MUST remain submittable while an interlock is tripped (§8.5), since they are the means of recovery. (A command that clears an interlock therefore normally declares `S0` and lists it in `clears_interlocks`.) -- What counts as an acceptable `confirmation` is deployment policy in v0.2. - A conforming server MAY accept any non-empty string, MAY compare against a - configured token, or MAY implement a stronger scheme. - -**Honest limitation.** v0.2 specifies *where* confirmation is enforced, not -*who* confirmed. A shared token proves an operator configured the deployment -to permit this class of action; it does not cryptographically bind a named -operator to a specific task. LAP's design: a JWS operator token bound to -the exact task and the hash of its canonical parameters, is the more -complete answer, and adopting an equivalent is a tracked roadmap item -(§13, [ROADMAP.md](../ROADMAP.md)). Deployments where that distinction -matters should not treat v0.2 confirmation as an audit control. + +**S2: confirmation.** Servers MUST reject a `command/submit` for an `S2` +command that carries no acceptable `confirmation` value, with error +`-32009` (`confirmation_required`), `retryable: false`, and +`data.details.safety_class` set to `"S2"`. What counts as acceptable is +deployment policy: a conforming server MAY accept any non-empty string, +MAY compare against a configured token, or MAY implement a stronger +scheme. A standing confirmation for a session of routine `S2` work is the +intended pattern. + +**S3: operator grants.** An operator grant is a record in a server-side +**grant store**, provisioned out of band (configuration or environment, +e.g. a directory the server reads and an operator tool writes). Normative: + +- **The protocol MUST NOT provide any method that creates, modifies, + extends, enumerates, or reveals grants, and a conforming implementation + MUST NOT add one as a vendor extension.** Whatever an agent can do over + this protocol, minting authorization is not part of it. +- A grant binds, at minimum: the instrument's `serial_number`, one + `command` name, one `params_digest`, a validity window + (`[not_before, expires_at)`), and a use limit (`max_uses`, with a + persistent use count). `params_digest` is + `"sha256:" + lowercase-hex(SHA-256(JCS(params)))` over the **normalized** + parameter object of §8.2, canonicalized per RFC 8785. Binding an operator + authorization to the capability and to a digest of its canonical + parameters is LAP's design ([arXiv:2606.03755](https://arxiv.org/abs/2606.03755)), + adopted here with credit; LAP binds a JWS operator token, and v0.3 keeps + the binding while deferring the signature (§14). +- A `confirmation` value MUST NOT satisfy an `S3` command, whatever it + contains. +- On an `S3` submit that fails authorization, the server MUST reject with + `-32011` (`authorization_required`), `retryable: false`, and + `data.details` carrying at minimum: `safety_class`, `command`, a + `reason` from the enum below, `params_digest`, `digest_alg`, + `canonicalization`, and `mintable_by_agent: false`. Before refusing a + submission whose only failure is a missing grant, the server SHOULD + record a **pending authorization request**, capped in number and + expiring, holding the command name, the normalized parameters verbatim, + the digest, and the instrument identity, and SHOULD include its + `request_id` and a server-configured `operator_instruction` in the error + details. A pending request is a description of a request, not an + authorization: recording one grants nothing. It exists so the operator's + approval tool reads the parameters from the **server's own store**, + never from a digest relayed through the agent that wants the approval. +- `reason` is one of: `absent` (no `authorization`, or a `confirmation` + offered instead), `unsupported_scheme`, `unknown`, `command_mismatch`, + `params_mismatch`, `instrument_mismatch`, `not_yet_valid`, `expired`, + `exhausted`, `revoked`. `params_mismatch` is the reason that proves the + binding is to parameters rather than an S3-shaped password: a valid, + unexpired grant for the same command still fails on different values. +- On success the server MUST **atomically** consume one use (increment and + persist the count) before creating the run; two concurrent submits MUST + NOT both spend the last use of a grant, and a restart MUST NOT resurrect + a spent one. Expiry remains the durable bound if the store is lost. +- A grant id is a bearer value. Servers SHOULD generate ids with at least + 128 bits of entropy, and MUST NOT write a grant id into any durable + artifact (§13.1 records a digest of it instead). + +**What a grant proves.** A verified grant proves that someone with write +access to this server's grant store approved this command name with these +exact parameter values, as the server itself recorded and displayed them, +within a time window and a bounded number of uses. It does **not** prove +who that person was, that the presenter is that person, or that anyone was +physically present: `issued_by` and `note` fields in a store are labels, +not authenticated identity. Cryptographic operator identity, a JWS profile +with key distribution and revocation, remains future work (§14, +[ROADMAP.md](../ROADMAP.md)). v0.2 proved deployment policy; v0.3 proves +deployment policy **plus parameter binding plus a bounded window**, and +still not identity. Deployments where identity matters should not treat a +v0.3 grant as an audit control over *who*. ## 9. Streaming Telemetry @@ -565,7 +764,160 @@ coalesce samples, but `seq` MUST remain monotonic per channel so clients can detect gaps. Clients MUST NOT assume lossless delivery. Servers MUST deliver samples for one channel to one subscription in `seq` order. -## 10. Events +## 10. Resources + +Instrument state that is a tree has to live somewhere. The descriptor is +static capability discovery; telemetry is unit-bearing scalars in a time +series; a deck that changes between runs fits neither, which is why v0.2 +implementations smuggled it through ordinary command results that nothing +marked as special. Resources give it a first-class home: declared in +discovery (§7.6), read with one method, revisioned so staleness is +detectable, and indexed so typed references (§7.2) have something +protocol-defined to resolve against. + +### 10.1 URIs + +A resource identifier is `labwire:` followed by a rootless path (RFC 3986 +`path-rootless`): + +``` +labwire:deck +labwire:deck/source_plate +labwire:deck/source_plate/A1 +``` + +The first segment names a resource declared in the descriptor; further +segments name items within it. Segment text containing `/`, `?`, `#`, or +`%` MUST be percent-encoded. There is exactly one spelling of any URI: a +server MUST reject an alternative form that would resolve to the same +thing, rather than canonicalizing it. + +**Child composition is protocol-defined, and instruments MUST NOT define +another.** An item URI is ` "/" `, where `` comes from +the read result's index (§10.2). This one rule is what keeps addressing +out of per-instrument convention: an agent that can read an index can +construct every legal reference on any conforming instrument, and there is +no grammar to learn or to guess. Ids are enumerated rather than templated +for the same reason: a template is a grammar. + +The `labwire:` scheme is provisional and unregistered. + + +### 10.2 resource/read + +`resource/read` params: `uri` (string, REQUIRED): a resource URI declared +in the descriptor. Reading an item URI is not supported in v0.3; clients +read the resource and join on the index. An unknown, undeclared, or +malformed `uri` MUST be rejected with `-32010` (`unknown_reference`, +`reason: "unknown_resource"` or `"malformed_uri"`), so there is one story +about URIs that do not resolve rather than two. + +Result: + +- `uri` (string, REQUIRED): as requested. +- `kind` (string, REQUIRED): as declared. +- `revision` (string, REQUIRED): see §10.3. +- `read_at` (string, REQUIRED): RFC 3339 UTC timestamp of this read. +- `index_complete` (boolean, REQUIRED): whether `index` enumerates every + resolvable reference. A server MAY set `false` for a resource it cannot + enumerate exhaustively; it MUST still resolve references correctly, and + a client MUST NOT infer non-existence from absence in an incomplete + index. +- `index` (array, REQUIRED): the **reference index**. Each entry: + - `uri` (string, REQUIRED): the entry's own URI. + - `kinds` (array of strings, REQUIRED): every kind this entry satisfies, + most specific first (a trough is `["trough", "container", "labware"]`). + A reference declaring kind K resolves to this entry iff K is in this + array; there is no subtyping graph in the protocol, the instrument + declares the set. + - `title` (string, OPTIONAL): a short label. + - `children` (object, OPTIONAL): `{kinds, ids}`; the entry has one item + per id, each with URI `/` and the given `kinds`. A + 96-well plate lists 96 ids rather than a range expression. +- `content` (REQUIRED): instrument-defined state conforming to the + declared `content_schema` (§7.6). Where content describes a referenceable + thing it MUST identify it by `uri`, so a client can join content to + index. + +A reference value V resolves iff some index entry E has `E.uri == V` +(satisfying `E.kinds`), or some entry E has `children` and +`V == E.uri + "/" + id` for an id in `E.children.ids` (satisfying +`E.children.kinds`). + +Resources are read-only; there is no write method (§7.6). No pagination is +defined in v0.3; a resource whose index would be impractically large (a +1536-well plate is ~9 KB of ids and is fine; a plate hotel of thousands of +positions may not be) is a known open problem recorded in §15.2. + +### 10.3 Revisions + +`revision` is an opaque string that MUST change whenever a read of the +resource would return different `index` or `content`, and MUST NOT be +interpreted by clients beyond equality. RECOMMENDED construction is a +per-process nonce plus a counter; the reference implementation derives it +as a truncated hash of the canonicalized read result, which makes "the +driver forgot to bump it" impossible. Reference validation (§10.4) never +consults a revision, so a defective revision can at worst cost a missed +notification or a spurious `-32012`, never a wrong validation. + +**Change notification** reuses the event channel (§11) under the reserved +name `resource/changed`, with `data: {uri, revision}`. Delivery is +best-effort exactly as §11 specifies; the revision in the payload lets a +client discard stale notifications. There is no per-resource subscription: +events are already broadcast to every operational session, and a second +push model for a handful of resources is surface without power. Because +events are written into active run records (§11), a signed manifest's +event stream also witnesses every deck change during the run. + +### 10.4 Reference validation at submission + +For each string location in the validated `params` whose schema node +carries `resource_ref` (including inside arrays), the server MUST resolve +the value against a **fresh** read of the declared `enumerated_by` +resource, per §10.2, checking that the resolved entry satisfies +`resource_ref.kind`. On the first failure the server MUST reject the +submission with `-32010` (`unknown_reference`), `retryable: false`, and +`data.details` carrying at minimum: `pointer` (an RFC 6901 pointer into +`params`, so the second element of an array is nameable), `parameter`, +`reference` (the offending value), `expected_kind`, `enumerated_by`, and +a `reason` from: `malformed_uri`, `unknown_resource`, `no_such_item`, +`kind_mismatch`. Servers SHOULD add `resolved_prefix` (the longest prefix +that did resolve) with its `resolved_kinds`, an OPTIONAL `did_you_mean` +list (capped, filtered by `expected_kind`), and `read`: a literal, +ready-to-send `resource/read` request object, so "I do not know what to +pass" becomes a call the agent can make without parsing prose. + +Validation MUST use current state, not a cache keyed by revision: a +defective revision must not let a reference to moved labware pass. This +check is time-of-check-to-time-of-use: labware can move between validation +and execution, and v0.3 does not close that window (§14). `if_revision` +narrows it (§10.5). + +### 10.5 Optimistic concurrency: if_revision + +A client that plans against a resource read MAY assert its plan is still +valid by sending `if_revision` on `command/submit`: an object mapping each +resource URI it planned against to the `revision` it read. For each entry, +the server MUST compare against the resource's current revision and reject +on the first mismatch with `-32012` (`stale_revision`), `retryable: +false`, and `data.details` carrying `uri`, `submitted_revision`, +`current_revision`, and a ready-to-send `read` object. No run is created, +no confirmation is consumed, and no grant use is spent: staleness is +checked before authorization precisely so a stale plan never costs an +operator approval. + +`if_revision` is an envelope field, never part of the normalized +parameters or the authorization digest (§8.2): an operator approves an +action, not a snapshot, and re-reading the deck after approval does not +invalidate a grant. A run's terminal status returns the new revisions +(§8.2), so a single agent driving an instrument can maintain freshness +without ever re-reading. What `if_revision` does not provide is a +reservation: between the check and a concurrent client's next write there +is no lock, and reservation leases remain future work (§14, +[ROADMAP.md](../ROADMAP.md)). + +## 11. Events Events report discrete occurrences; telemetry reports sampled values. The server pushes `notifications/event`: @@ -575,7 +927,7 @@ server pushes `notifications/event`: - `severity` (string, REQUIRED): `"info"`, `"warning"`, or `"alarm"`. - `data` (object, REQUIRED): event-specific payload; MAY be `{}`. -Reserved event names in v0.2 (servers MUST use these names for these +Reserved event names in v0.3 (servers MUST use these names for these meanings): | Name | Meaning | `data` | @@ -584,18 +936,19 @@ meanings): | `interlock/tripped` | A declared interlock tripped | `{interlock}` (its declared name) | | `interlock/cleared` | A tripped interlock cleared | `{interlock}` | | `measurement/stable` | A measurement reached stability (e.g. a balance settling) | `{channel, value}` | -| `error/occurred` | An error not attributable to one run | Error object (§11.2) | +| `error/occurred` | An error not attributable to one run | Error object (§12.2) | +| `resource/changed` | A resource's content or index changed (§10.3) | `{uri, revision}` | Other event names are instrument-defined; vendor extensions MUST use the `x-/` prefix (§7.5). Servers whose `events` capability is `true` MUST deliver every event to every operational session; there is no event -subscription in v0.2. Events MUST be delivered to a session in emission +subscription in v0.3. Events MUST be delivered to a session in emission order; delivery is best-effort, but events of severity `alarm` SHOULD NOT be dropped. -## 11. Error Taxonomy +## 12. Error Taxonomy -### 11.1 Codes +### 12.1 Codes Standard JSON-RPC codes apply to protocol-level failures: `-32700` (parse error), `-32600` (invalid request), `-32601` (method not found), `-32602` @@ -615,7 +968,10 @@ Labwire domain errors use the JSON-RPC server-error range: | -32006 | `canceled` | The run was canceled | no | | -32007 | `not_cancelable` | Cancel requested for a run that cannot be canceled | no | | -32008 | `internal` | Unexpected server error | no | -| -32009 | `confirmation_required` | An `S2`/`S3` command was submitted without an acceptable `confirmation` (§8.6) | no | +| -32009 | `confirmation_required` | An `S2` command was submitted without an acceptable `confirmation` (§8.6) | no | +| -32010 | `unknown_reference` | A `resource_ref` parameter value, or a `resource/read` URI, does not resolve in current resource state (§10.4) | no | +| -32011 | `authorization_required` | An `S3` command was submitted without a verifiable operator grant for these exact parameters (§8.6) | no | +| -32012 | `stale_revision` | An `if_revision` precondition did not match the resource's current revision (§10.5) | no | The "Retryable" column is the REQUIRED default for the `retryable` field; servers MAY override it per error instance (e.g. a transient @@ -624,18 +980,26 @@ servers MAY override it per error instance (e.g. a transient When multiple rejection rules apply to one request, precedence is: not-initialized (`-32002`) → method not found (`-32601`) → invalid method params (`-32602`) → `unsupported` (`-32001`) → `validation` (`-32000`) → -`confirmation_required` (`-32009`) → `interlock` (`-32003`) → capacity -`busy` (`-32002`). Validation precedes confirmation so that an agent is -never asked to confirm a request that could never run. +`unknown_reference` (`-32010`) → `stale_revision` (`-32012`) → +`interlock` (`-32003`) → capacity `busy` (`-32002`) → +`confirmation_required` (`-32009`) / `authorization_required` (`-32011`). + +This order applies one principle consistently: **everything knowable +without an operator is checked first**, so an agent is never asked to +confirm, and a single-use grant is never spent, on a call that could not +have run. It moves `interlock` and capacity ahead of confirmation +relative to v0.2, which had already stated the principle for validation. +The reordering cannot deadlock recovery: interlock-clearing commands are +`S0` and exempt from the interlock check (§8.5). -### 11.2 Error object +### 12.2 Error object Everywhere an error appears, JSON-RPC `error` member, or CommandStatus `error` field: it is: - `code` (integer, REQUIRED) - `message` (string, REQUIRED): human-readable, one line. -- `data` (object, REQUIRED for codes -32000..-32009): +- `data` (object, REQUIRED for codes -32000..-32012): - `category` (string, REQUIRED): from the table above. - `retryable` (boolean, REQUIRED): whether the same request MAY succeed if retried without operator intervention. Agents SHOULD key retry policy off @@ -646,7 +1010,7 @@ Everywhere an error appears, JSON-RPC `error` member, or CommandStatus Servers MUST NOT leak stack traces or internal paths in `message` or `details`. -## 12. Signed Run Manifests +## 13. Signed Run Manifests Every run that reaches a terminal state SHOULD produce a **run manifest**: a portable, verifiable record of what instrument did what, with which @@ -655,21 +1019,21 @@ attributable and tamper-evident. Servers advertising the `manifests` capability (§6.1) MUST produce a manifest for every terminal run. **How manifests are surfaced to consumers -is implementation-defined in v0.2**: no protocol method carries manifests; +is implementation-defined in v0.3**: no protocol method carries manifests; the reference implementation writes a bundle (manifest + record stream) per run to a local directory. A future protocol version may add a retrieval method. > **Conformance note:** the manifest format is normative; the reference -> implementation produces and verifies these bundles (§14.2). +> implementation produces and verifies these bundles (§15.2). -### 12.1 Manifest document +### 13.1 Manifest document ```json { - "manifest_version": "0.2", - "protocol_version": "0.2", + "manifest_version": "0.3", + "protocol_version": "0.3", "run_id": "b7e0a1c2-4d5e-4f60-8a9b-0c1d2e3f4a5b", "instrument": { "manufacturer": "Labwire Project", @@ -681,7 +1045,8 @@ method. "command": { "name": "measure", "params": { "settle_timeout_s": 30.0 }, - "safety_class": "S1" + "safety_class": "S1", + "params_digest": "sha256:b8a66f00ce786f5fb861ea0d72562e611c8a0332c7ee5adc2dc88a4a2b527561" }, "status": "succeeded", "result": { "mass_g": 12.3456 }, @@ -704,24 +1069,49 @@ method. ``` (`digest` is illustrative; `key_id` is the genuine SHA-256 of the example -`public_key`.) +`public_key`, and `params_digest` is the genuine digest of the example +`params` per §8.6.) All fields are REQUIRED unless marked otherwise: -- `manifest_version` (string): `"0.2"` for this document. +- `manifest_version` (string): `"0.3"` for this document. Verifiers MUST + also accept `"0.2"` bundles, which lack the members introduced below; + the format change breaks producers, not verifiers. - `protocol_version` (string): the negotiated protocol version (§4). - `run_id` (string): the run's `command_id`. - `instrument` (object): the `identity` object from the descriptor (§7.1), verbatim. -- `command` (object): `name` (string), `params` (object): the submitted - command verbatim, and `safety_class` (string): the class the server - enforced for it (§8.6). All three are covered by the signature, so a - manifest records what safety posture applied to the run. +- `command` (object): `name` (string), `params` (object): the + **normalized** parameters of §8.2, with schema defaults applied, which + are also the digest input, and `safety_class` (string): the class the + server enforced for it (§8.6). In v0.2 `params` recorded the raw + submission, so a command with defaulted optionals signed a manifest + describing something other than what ran; recording the normalized + object closes that, and lets an auditor recompute `params_digest` + offline from the bundle alone. `params_digest` (string): the digest of + §8.6, present for every run in a 0.3 manifest. All are covered by the + signature, so a manifest records what safety posture applied to the run. +- `authorization` (object, present iff the command's class is `S2` or + `S3`): how the run was authorized. For `S2`: + `{"mode": "confirmation", "identity_verified": false}`. For `S3`: + `mode` is `"grant"`, with `request_id` (string, OPTIONAL), an + `expires_at` and `use_index` describing the grant use, free-text + `issued_by` and `note` copied from the store **and labelled by this + specification as unauthenticated**, `grant_digest` (string): + `"sha256:"` + hex SHA-256 of the grant id, and `identity_verified` + (boolean, REQUIRED): MUST be `false` in v0.3. The grant id itself MUST + NOT appear: it is a bearer value and a signed bundle is a durable + artifact. `identity_verified` exists so the honesty caveat of §8.6 is a + machine-checkable wire fact rather than prose: verifiers MUST surface + it, and a future version with cryptographic operator identity flips it. +- `resource_revisions` (array, OPTIONAL): for each resource the run + changed, `{uri, revision_at_start, revision_at_end}`, so an auditor can + ask whether state moved under the run. - `status` (string): the run's terminal state (§8.1). - `result` (any, present iff `status` is `succeeded` and the command returned a value): the command's result, verbatim. - `error` (object, present iff `status` is `failed`): the Error object - (§11.2). + (§12.2). - `data` (object): - `digest_alg` (string): `"sha256"`, the only permitted v0.2 value. - `digest` (string): lowercase hex SHA-256 of the run's **record @@ -743,7 +1133,7 @@ the server's emission order, of: - for each sample produced on a channel listed in `data.channels` with timestamp in [`timestamps.started`, `timestamps.completed`]: the JCS - canonicalization (§12.2) of + canonicalization (§13.2) of `{"type": "sample", "channel": ..., "seq": ..., "timestamp": ..., "value": ...}`; - for each event emitted in that window: the JCS canonicalization of `{"type": "event", "name": ..., "timestamp": ..., "severity": ..., "data": ...}`; @@ -754,7 +1144,7 @@ notifications. An empty record stream digests to the SHA-256 of zero bytes (`e3b0c442…b855`). Bundles SHOULD include the record stream itself so verifiers can recompute `digest`. -### 12.2 Canonicalization and signature +### 13.2 Canonicalization and signature The signature is computed as: @@ -769,39 +1159,56 @@ Verification: remove `signature`, canonicalize per JCS, verify against `signer.public_key`, and check `signer.key_id` matches that key. Verifiers MUST reject a bundle whose `key_id` does not match its `public_key`. -### 12.3 Keys +### 13.3 Keys How a verifier comes to trust a public key is out of scope for v0.2. Servers SHOULD generate a keypair on first run and persist it; operators SHOULD record the `key_id` out of band (trust-on-first-use). This is stated plainly: v0.2 manifests prove *integrity* (the record wasn't altered) and *key continuity* (same signer as before), not *identity* (who the signer is). See -§13. +§14. -## 13. Security Considerations +## 14. Security Considerations -v0.2 is designed for **trusted environments**: localhost or an isolated lab +v0.3 is designed for **trusted environments**: localhost or an isolated lab network. Stated plainly: - **Authentication is a stub.** The client MAY present `api_key` in `initialize` (§6.1); a server configured with a key MUST reject initialization on mismatch with error `-32000` (`validation`). There is no authorization model, no user identity, and no key rotation in v0.2. -- **Safety confirmation proves policy, not identity.** The `confirmation` - value that gates `S2`/`S3` commands (§8.6) shows that whoever holds the - deployment's token permitted this class of action. It does **not** identify - an operator, bind them to a specific task, or produce an audit trail. - Cryptographic operator binding: an operator token signed over the task and - the hash of its canonical parameters, as LAP specifies - ([arXiv:2606.03755](https://arxiv.org/abs/2606.03755)): is the intended - successor and is tracked in [ROADMAP.md](../ROADMAP.md). Until then, - deployments MUST NOT rely on `confirmation` as an accountability control. +- **Authorization proves policy and binding, not identity.** An `S2` + `confirmation` shows that whoever holds the deployment's token permitted + this class of action. An `S3` grant (§8.6) proves more: someone with + write access to the server's grant store approved this command with + these exact parameter values, within a window and a use limit. Neither + identifies *who*. A grant id is a bearer value on a transport that only + SHOULD use TLS, replayable within its window by anyone who can read the + traffic; single use and short expiry bound the damage, and the manifest + records only its digest. On a single machine, an operator console and an + agent running as the same user are not separated by anything this + protocol can enforce; the grant store's write permissions are the actual + boundary, and deployments where it matters put the store where the agent + cannot write. Cryptographic operator identity: a JWS operator token + signed over the task and the hash of its canonical parameters, as LAP + specifies ([arXiv:2606.03755](https://arxiv.org/abs/2606.03755)): is the + intended successor and is tracked in [ROADMAP.md](../ROADMAP.md). Until + then, deployments MUST NOT rely on `confirmation` or grants as an + accountability control over identity; `identity_verified: false` in + every v0.3 manifest (§13.1) states this on the wire. +- **Reference validation is time-of-check-to-time-of-use.** A reference + resolves at submission against state that can change before or during + execution, and v0.3 provides no lock. `if_revision` (§10.5) narrows the + window and never closes it; reservation leases are future work. v0.3 + ships **no lost-update protection**: two clients interleaving on one + instrument can invalidate each other's plans without either seeing an + error, unless both use `if_revision` and re-read on `-32012`. - **Transport security.** Deployments that cross any network boundary SHOULD use `wss://` (TLS). The protocol itself provides no confidentiality. - **Manifest guarantees** are limited to integrity and key continuity - (§12.3). A manifest does not prove the physical sample, the operator, or + (§13.3). A manifest does not prove the physical sample, the operator, or the calibration state. - **Agent-facing strings are untrusted input.** Descriptor fields (`title`, `description`, event payloads, error messages) flow into AI agent @@ -814,21 +1221,21 @@ network. Stated plainly: constrains the protocol (§8.5); a malicious server can lie about it. Physical safety MUST be enforced in the instrument, not in this protocol. -## 14. Conformance +## 15. Conformance -### 14.1 Conformance levels +### 15.1 Conformance levels | Level | Requirements | |---|---| -| **Core** | One transport (§5); `initialize`, `ping`, `notifications/initialized` (§6); `instrument/describe` (§7); command lifecycle with push status and polling (§8); error taxonomy (§11) | -| **Streaming** | Core + telemetry (§9) + events (§10) | -| **Signed** | Streaming + run manifests (§12) | +| **Core** | One transport (§5); `initialize`, `ping`, `notifications/initialized` (§6); `instrument/describe` (§7); command lifecycle with push status and polling (§8); error taxonomy (§12) | +| **Streaming** | Core + telemetry (§9) + events (§11) | +| **Signed** | Streaming + run manifests (§13) | A server MUST document its level. A client MUST tolerate a server of any level (the capability flags in the `initialize` *result* tell it what to expect). -### 14.2 Reference implementation status (v0.2) +### 15.2 Reference implementation status (v0.3) Honesty table, what the reference implementation in this repository implements: @@ -837,18 +1244,22 @@ implements: |---|---| | §5.1 WebSocket transport | Implemented | | §5.2 stdio transport | **Specified only**: no consumer yet; implementation unscheduled | -| §6 session lifecycle, §7 discovery, §8 commands, §9 telemetry, §10 events, §11 errors | Implemented | +| §6 session lifecycle, §7 discovery, §8 commands, §9 telemetry, §11 events, §12 errors | Implemented | | §7.2/§7.3 mandatory UCUM codes | Implemented, presence enforced at declaration and on the wire; UCUM **grammar** not parsed | | §7.2 `qudt_quantity_kind` | Implemented as a pass-through declaration; no QUDT reasoning | -| §8.6 safety classes | Implemented, `S2`/`S3` confirmation enforced with a configured-token stub | -| §8.6 operator-bound confirmation | **Not implemented**: see §13 and ROADMAP.md | -| §12 signed manifests | Implemented: bundle = `manifest.json` + `records.jsonl`, verified by `labwire verify` | -| §13 `api_key` stub | **Deferred, unscheduled** | +| §7.2 typed references (`resource_ref`) | Implemented: shape at declaration, closure at server construction, resolution at submission | +| §7.6/§10 resources | Implemented: declaration, `resource/read`, derived revisions, `resource/changed`. **No pagination**; a very large index is an open problem. **No caching**: every reference validation re-reads | +| §8.6 `S2` confirmation | Implemented with a configured-token stub | +| §8.6 `S3` operator grants | Implemented: file-backed store, pending requests, atomic use counts, `labwire grant`. **Assumes the store lives where the agent cannot write; nothing in-protocol enforces that** | +| §8.6 cryptographic operator identity | **Not implemented**: `identity_verified` is `false` in every manifest; see §14 and ROADMAP.md | +| §10.5 `if_revision` | Implemented. **No reservation, no lost-update protection beyond it** | +| §13 signed manifests | Implemented: bundle = `manifest.json` + `records.jsonl`, verified by `labwire verify`; 0.2 and 0.3 bundles both verify | +| §14 `api_key` stub | **Deferred, unscheduled** | | In-memory transport (test-only; not a §5 transport) | Implemented | This table is updated with each release. -## 15. JSON Message Reference +## 16. JSON Message Reference Every protocol message, one example each. Examples are normative for shape. @@ -857,7 +1268,7 @@ Marker grammar: the first line *inside* each fenced JSON block is or one of the literals `error` and `manifest`, and `` is one of `request`, `result`, `notification`, `notification-terminal`, `response`, `document`, `signature-excerpt`. The reference implementation's test suite -extracts every marked block in this document (including §12.1), strips the +extracts every marked block in this document (including §13.1), strips the marker line, and round-trips the JSON through the message model registered for ``: failing if any example does not round-trip. Blocks whose kind is `signature-excerpt` are validated only for the fields present. @@ -866,7 +1277,7 @@ Examples are independent snapshots, not one session timeline; `id`, `command_id`, and hash/signature values are illustrative unless stated otherwise. -### 15.1 initialize +### 16.1 initialize ```json @@ -895,7 +1306,7 @@ otherwise. } ``` -### 15.2 notifications/initialized +### 16.2 notifications/initialized ```json @@ -906,7 +1317,7 @@ otherwise. } ``` -### 15.3 ping +### 16.3 ping ```json @@ -918,7 +1329,7 @@ otherwise. { "jsonrpc": "2.0", "id": 2, "result": {} } ``` -### 15.4 instrument/describe +### 16.4 instrument/describe ```json @@ -998,12 +1409,167 @@ otherwise. "tripped": false } ], + "resources": [ + { + "uri": "labwire:syringe", + "kind": "consumable", + "title": "Installed syringe", + "description": "The syringe currently installed in the pump: its model, capacity, and how much it holds. Changes when a syringe is exchanged or the plunger moves.", + "item_kinds": [], + "revision": "b2c4e6a8-17", + "content_schema": { + "type": "object", + "additionalProperties": false, + "required": ["model", "capacity_ul", "installed_ul"], + "properties": { + "model": { "type": "string" }, + "capacity_ul": { "type": "number", "unit": "uL" }, + "barrel_diameter_mm": { "type": "number", "unit": "mm" }, + "installed_ul": { "type": "number", "unit": "uL" } + } + } + } + ], + "max_concurrent_commands": 1 + } +} +``` + +An instrument with tree-shaped state and typed references declares them +together; a fragment of a liquid handler's descriptor: + +```json + +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "identity": { + "manufacturer": "PyLabRobot bridge (Labwire)", + "model": "LiquidHandlerChatterboxBackend", + "serial_number": "dilution-rig", + "firmware_version": "0.3.0" + }, + "commands": [ + { + "name": "transfer", + "title": "Transfer", + "description": "Move liquid from one container into one or more others, aspirating and dispensing in one command.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": ["source", "targets", "volumes_ul"], + "properties": { + "source": { + "type": "string", + "resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" }, + "description": "The container to draw from. Must be a container listed in the index of resource labwire:deck; read that resource for the valid values." + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" } + } + }, + "volumes_ul": { + "type": "array", + "minItems": 1, + "items": { "type": "number", "exclusiveMinimum": 0 } + } + } + }, + "unit_annotations": { "volumes_ul": "uL" }, + "returns_units": { "total_volume_ul": "uL" }, + "safety_class": "S2", + "interruptible": true + } + ], + "channels": [], + "interlocks": [], + "resources": [ + { + "uri": "labwire:deck", + "kind": "deck", + "title": "Deck", + "description": "What is on the deck right now. Every container, tip site, labware and site a command parameter can name is listed in this resource's index. Changes whenever labware or liquid moves.", + "item_kinds": ["labware", "plate", "tip_rack", "container", "tip_site", "site", "trash"], + "revision": "9f3c1a4e-131", + "content_schema": { + "type": "object", + "additionalProperties": false, + "required": ["contents"], + "properties": { + "contents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["uri", "volume_ul"], + "properties": { + "uri": { "type": "string" }, + "volume_ul": { "type": "number", "unit": "uL" }, + "max_volume_ul": { "type": ["number", "null"], "unit": "uL" } + } + } + } + } + } + } + ], "max_concurrent_commands": 1 } } ``` -### 15.5 command/submit +### 16.5 resource/read + +```json + +{ "jsonrpc": "2.0", "id": 10, "method": "resource/read", "params": { "uri": "labwire:deck" } } +``` + +```json + +{ + "jsonrpc": "2.0", + "id": 10, + "result": { + "uri": "labwire:deck", + "kind": "deck", + "revision": "9f3c1a4e-131", + "read_at": "2026-07-27T09:14:02.115430Z", + "index_complete": true, + "index": [ + { + "uri": "labwire:deck/tips", + "kinds": ["tip_rack", "labware"], + "title": "tips", + "children": { "kinds": ["tip_site"], "ids": ["A1", "B1", "C1", "D1"] } + }, + { + "uri": "labwire:deck/source_plate", + "kinds": ["plate", "labware"], + "title": "source_plate", + "children": { "kinds": ["container"], "ids": ["A1", "A2", "B1", "B2"] } + }, + { "uri": "labwire:deck/staging-0", "kinds": ["site"], "title": "staging-0" }, + { "uri": "labwire:deck/trash", "kinds": ["trash", "labware"], "title": "trash" } + ], + "content": { + "contents": [ + { "uri": "labwire:deck/source_plate/A1", "volume_ul": 300.0, "max_volume_ul": 360.0 } + ] + } + } +} +``` + +(`ids` arrays abbreviated; a real 96-well plate lists 96 two-character ids, +about 600 bytes.) + +### 16.6 command/submit ```json @@ -1023,6 +1589,29 @@ otherwise. required; submitting the same request without it is rejected with `-32009` (`confirmation_required`). An `S0` or `S1` command needs no such field. +An `S3` command takes an operator grant instead, and MAY carry +`if_revision` asserting the resource state the plan was made against +(§10.5). Note the reference-valued parameters and that no `confirmation` +appears: one would not satisfy `S3`. + +```json + +{ + "jsonrpc": "2.0", + "id": 18, + "method": "command/submit", + "params": { + "command": "move_plate", + "params": { + "plate": "labwire:deck/dilution_plate", + "to": "labwire:deck/staging-0" + }, + "authorization": { "grant_id": "g-7f2a91c4" }, + "if_revision": { "labwire:deck": "9f3c1a4e-131" } + } +} +``` + ```json { @@ -1035,7 +1624,7 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.6 notifications/command_status +### 16.7 notifications/command_status ```json @@ -1058,12 +1647,15 @@ required; submitting the same request without it is rejected with `-32009` "params": { "command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b", "status": "succeeded", - "result": { "dispensed_ul": 500.0 } + "result": { "dispensed_ul": 500.0 }, + "resource_revisions": [ + { "uri": "labwire:syringe", "revision": "b2c4e6a8-18" } + ] } } ``` -### 15.7 command/status +### 16.8 command/status ```json @@ -1088,7 +1680,7 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.8 command/cancel +### 16.9 command/cancel ```json @@ -1112,7 +1704,7 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.9 telemetry/subscribe +### 16.10 telemetry/subscribe ```json @@ -1133,7 +1725,7 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.10 telemetry/unsubscribe +### 16.11 telemetry/unsubscribe ```json @@ -1150,7 +1742,7 @@ required; submitting the same request without it is rejected with `-32009` { "jsonrpc": "2.0", "id": 8, "result": {} } ``` -### 15.11 notifications/telemetry +### 16.12 notifications/telemetry ```json @@ -1167,7 +1759,7 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.12 notifications/event +### 16.13 notifications/event ```json @@ -1183,7 +1775,23 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.13 Error response +A resource change rides the same channel under its reserved name (§10.3): + +```json + +{ + "jsonrpc": "2.0", + "method": "notifications/event", + "params": { + "name": "resource/changed", + "timestamp": "2026-07-27T09:14:07.884120Z", + "severity": "info", + "data": { "uri": "labwire:deck", "revision": "9f3c1a4e-142" } + } +} +``` + +### 16.14 Error response ```json @@ -1198,9 +1806,95 @@ required; submitting the same request without it is rejected with `-32009` } ``` -### 15.14 Signed manifest bundle +A failed reference resolution names the failure precisely and hands the +agent the read that recovers (§10.4): -The manifest document example appears in §12.1. The signed bundle adds the +```json + +{ + "jsonrpc": "2.0", + "id": 12, + "error": { + "code": -32010, + "message": "parameter /targets/1: 'labwire:deck/source_plate/A13' is not a container on this instrument", + "data": { + "category": "unknown_reference", + "retryable": false, + "details": { + "pointer": "/targets/1", + "parameter": "targets", + "reference": "labwire:deck/source_plate/A13", + "expected_kind": "container", + "enumerated_by": "labwire:deck", + "resolved_prefix": "labwire:deck/source_plate", + "resolved_kinds": ["plate", "labware"], + "reason": "no_such_item", + "did_you_mean": ["labwire:deck/source_plate/A1", "labwire:deck/source_plate/B1"], + "read": { "method": "resource/read", "params": { "uri": "labwire:deck" } } + } + } + } +} +``` + +A refused `S3` submission records a pending request and tells the agent, +in a typed field, that it cannot mint what is missing (§8.6): + +```json + +{ + "jsonrpc": "2.0", + "id": 19, + "error": { + "code": -32011, + "message": "move_plate is S3 and requires an operator grant bound to these exact parameters; a confirmation string cannot authorize it", + "data": { + "category": "authorization_required", + "retryable": false, + "details": { + "safety_class": "S3", + "command": "move_plate", + "reason": "absent", + "request_id": "req-3f1c8d9e", + "params_digest": "sha256:1c8d4fbb2e7a0f5d9c3b81a6e04f2d7c5b9e13a80f6c24d7e9b1a3c5f7d0e2b4", + "digest_alg": "sha256", + "canonicalization": "RFC8785", + "mintable_by_agent": false, + "operator_instruction": "On the instrument host run: labwire grant list, then labwire grant approve req-3f1c8d9e --ttl 15m --uses 1" + } + } + } +} +``` + +A stale plan is refused before any confirmation or grant is spent +(§10.5): + +```json + +{ + "jsonrpc": "2.0", + "id": 21, + "error": { + "code": -32012, + "message": "labwire:deck has moved since this plan was made", + "data": { + "category": "stale_revision", + "retryable": false, + "details": { + "uri": "labwire:deck", + "submitted_revision": "9f3c1a4e-131", + "current_revision": "9f3c1a4e-142", + "read": { "method": "resource/read", "params": { "uri": "labwire:deck" } } + } + } + } +} +``` + +### 16.15 Signed manifest bundle + +The manifest document example appears in §13.1. The signed bundle adds the `signature` field: ```json @@ -1211,10 +1905,10 @@ The manifest document example appears in §12.1. The signed bundle adds the } ``` -(All other manifest fields as §12.1; abbreviated here for length. The +(All other manifest fields as §13.1; abbreviated here for length. The `signature` value is illustrative, not a real signature over this example.) -## 16. Acknowledgments +## 17. Acknowledgments Labwire borrows deliberately from prior art, with gratitude: @@ -1232,16 +1926,60 @@ Labwire borrows deliberately from prior art, with gratitude: - **LAP** ([arXiv:2606.03755](https://arxiv.org/abs/2606.03755)): the - mandatory-UCUM discipline for every quantity (§7.2, §7.3) and the S0-S3 + mandatory-UCUM discipline for every quantity (§7.2, §7.3), the S0-S3 safety-class taxonomy with confirmation for costly and hazardous actions - (§8.6). Labwire and LAP are independent, convergent designs; these two - ideas are adopted from LAP with thanks. + (§8.6), and the binding of an operator authorization to a capability and + to a digest of its canonical parameters (§8.6). LAP binds a JWS operator + token; v0.3 keeps the binding and defers the signature. Labwire and LAP + are independent, convergent designs; these ideas are adopted from LAP + with thanks, and no compatibility or endorsement is claimed. +- **W3C Web of Things Thing Description:** the placement of semantics + inside an interaction affordance's data schema rather than in a side + table, which decided `resource_ref` and the content-schema `unit` + keyword (§7.2, §7.6), and the `unit` term itself. + +- **JSON-LD:** the intuition that a value can be a typed link to a named + node rather than a literal. Labwire v0.3 is **not** JSON-LD: there is no + `@context`, `labwire:` URIs are not IRIs into a shared vocabulary, and + `kind` is matched within one instrument against Appendix A alone. The + intuition is borrowed; the machinery is deliberately not. +- **MCP resources:** the resource primitive itself, reduced to one read + method and in-descriptor declaration (§10). +- **HTTP (RFC 9110):** conditional-request thinking behind `revision`, + `if_revision`, and terminal-status revision reporting (§10.3, §10.5). +- **RFC 3986 / RFC 6901 / RFC 8785:** the URI shape, the error pointer + form, and the canonicalization under every digest. A detailed, honest comparison, including what these systems do better than Labwire, lives in `PRIOR_ART.md` at the repository root. -## 17. Changelog - +## 18. Changelog + +- **0.3.0 (2026-07-27):** Protocol version `"0.3"`. Things, not only + quantities. **Added:** resources: URI-identified, typed, readable + instrument state declared in the descriptor (§7.6) and read with + `resource/read` (§10), with derived revisions and the reserved + `resource/changed` event; typed references: the `resource_ref` schema + keyword, validated against current resource state at submission with the + new error `-32010` (`unknown_reference`); operator grants for `S3`: + out-of-band provisioned, bound to a command and the RFC 8785 digest of + its normalized parameters (a binding adopted from LAP with credit), + expiring and use-limited, refused with the new error `-32011` + (`authorization_required`); optimistic concurrency: `if_revision` on + submit with the new error `-32012` (`stale_revision`), and + `resource_revisions` on terminal status. **Breaking:** + `InstrumentDescriptor.resources` is REQUIRED; a `confirmation` no longer + satisfies `S3`; submission precedence moves `interlock` and capacity + ahead of confirmation and authorization (§12.1); the error `data` + requirement extends to `-32012`; manifests are `"0.3"` with + `command.params` now the **normalized** parameters (in v0.2 a command + with defaulted optionals signed a manifest describing something other + than what ran), plus `params_digest`, `authorization` with a REQUIRED + `identity_verified: false`, and `resource_revisions`; the `unit` and + `resource_ref` schema keywords are claimed, `unit` REQUIRED on numeric + nodes in `content_schema` and forbidden in command schemas. Verifiers + accept both `"0.2"` and `"0.3"` bundles. - **0.2.1 (2026-07-27):** Corrective. The unit rule in §7.2 said "every numeric parameter (JSON Schema type `number` or `integer`)", which a reference implementation read literally, so an array of numbers carried no @@ -1260,12 +1998,45 @@ Labwire, lives in `PRIOR_ART.md` at the repository root. **Added:** per-command `safety_class` (`S0`-`S3`, default `S1`, §8.6) with mandatory `confirmation` on `S2`/`S3` submissions and the new error `-32009` (`confirmation_required`); optional `qudt_quantity_kind` - declarations; `command.safety_class` inside signed manifests (§12.1). - Units and the safety taxonomy are adopted from LAP with credit (§16). + declarations; `command.safety_class` inside signed manifests (§13.1). + Units and the safety taxonomy are adopted from LAP with credit (§17). - **0.1.0 (2026-07-23):** Initial draft. Protocol version `"0.1"`. --- +## Appendix A. Kind registry + +`kind` names without a dot are reserved for this registry; anything else +MUST take the form `.`, and clients MUST treat unrecognized +vendor kinds as opaque. This registry works the way UCUM codes do: an +instrument looks a name up, it does not invent one, because a kind two +instruments spell differently is the fragmentation typed references exist +to end. + +Stated honestly: this registry is seeded from the single domain that +forced the feature (liquid handling) and is maintained by this project +alone. It is expected to grow one proven need at a time, and a governance +process is future work recorded in ROADMAP.md. + +| Kind | Meaning | +|---|---| +| `deck` | The working area of a liquid handler | +| `labware` | Anything an instrument can hold or move | +| `plate` | A multi-well plate (also `labware`) | +| `tip_rack` | A rack of pipette tips (also `labware`) | +| `trough` | A single-cavity reservoir (also `container`, `labware`) | +| `trash` | A disposal target (also `labware`) | +| `lid` | A plate lid (also `labware`) | +| `container` | Holds liquid: a well, a tube, a trough cavity | +| `tip_site` | One spot of a tip rack | +| `site` | A position labware can stand on | +| `consumable` | An installed consumable: a syringe, a cartridge | + +An index entry lists **every** kind it satisfies (§10.2), most specific +first, so a trough entry reads `["trough", "container", "labware"]` and a +reference declaring any of the three resolves to it. There is no subtyping +graph in the protocol; the instrument declares the set. + ### References - [JSONRPC] JSON-RPC 2.0 Specification, https://www.jsonrpc.org/specification From aae0e720ad47edb773dcb1060491a6b8a170978c Mon Sep 17 00:00:00 2001 From: Silous Ramelli <204268110+TheRoboMaster123@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:10:50 -0700 Subject: [PATCH 2/5] feat(core)!: resources, typed references, and operator grants The v0.3 core. Protocol version is now "0.3", and the capability flags advertise what is genuinely true: resources and grants are implemented, so a server may say so. Resources are declared at class scope like channels: resource() takes a pydantic content model whose serialization schema becomes content_schema, validated at declaration with the same import-time discipline as @command, including the rule that numeric content carries unit keywords. A reader returns index and content in one snapshot; the revision is derived from the canonicalized read result, so a driver cannot forget to bump it, and touch() emits the reserved resource/changed event only when something actually moved. resource/read serves it; an unknown URI is refused naming what exists. Typed references resolve at submission against a fresh read, walking the schema and the instance together so the refusal carries an RFC 6901 pointer, the expected kind, the longest resolving prefix, did_you_mean candidates filtered by kind, and a ready-to-send read request. Closure is a descriptor-level validator, so a server cannot serve a dangling reference and a client refuses one on receipt. The submit chain now runs unsupported, validation, unknown_reference, stale_revision, interlock, busy, then confirmation or authorization: everything knowable without an operator first, so an agent is never asked to confirm, and a grant is never spent, on a call that could not run. One normalized parameter object is validated, executed, digested, and recorded, closing the v0.2 latent bug where a manifest described something other than what ran. S3 takes an operator grant. The store is three files with separated writers: grants.json only the operator tool writes, pending.jsonl and uses.json only the server writes, so use counts persist across restarts without the server ever touching the operator's file. Verification is synchronous with no awaits between check and consume, and a test proves eight concurrent submits spend a single-use grant exactly once. A refusal records a pending request, so labwire grant list shows the operator the real parameters from the server's own record, never a digest relayed through the agent. labwire grant approve mints the grant; revoke revokes it. A server declaring an S3 command with no store configured refuses to start. Manifests are 0.3: params_digest on every run, an authorization block whose identity_verified is required false, resource revision windows, and a verifier that accepts 0.2 bundles unchanged and refuses a 0.3 bundle claiming identity was verified, because that claim would be a lie in this version. if_revision ships per the approved decision: checked before authorization so a stale plan never costs an approval, never part of the digest so a re-read cannot invalidate a grant, and the terminal status returns the new revisions so a single agent never re-reads between steps. Co-Authored-By: Claude --- packages/cli/src/labwire/cli/main.py | 134 ++++ packages/core/src/labwire/core/__init__.py | 19 +- packages/core/src/labwire/core/_meta.py | 6 +- .../core/src/labwire/core/capabilities.py | 252 +++++++- packages/core/src/labwire/core/client.py | 30 +- packages/core/src/labwire/core/grants.py | 340 ++++++++++ packages/core/src/labwire/core/jcs.py | 17 + packages/core/src/labwire/core/server.py | 583 +++++++++++++++++- packages/core/src/labwire/core/signing.py | 30 +- packages/core/tests/test_grant_store.py | 171 +++++ packages/core/tests/test_resources.py | 472 ++++++++++++++ packages/core/tests/test_server_protocol.py | 2 +- packages/core/tests/test_smoke.py | 4 +- packages/core/tests/test_units_and_safety.py | 88 ++- 14 files changed, 2109 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/labwire/core/grants.py create mode 100644 packages/core/tests/test_grant_store.py create mode 100644 packages/core/tests/test_resources.py diff --git a/packages/cli/src/labwire/cli/main.py b/packages/cli/src/labwire/cli/main.py index 70920b8..3f922d7 100644 --- a/packages/cli/src/labwire/cli/main.py +++ b/packages/cli/src/labwire/cli/main.py @@ -4,12 +4,17 @@ >>> # $ labwire verify runs/ """ +import contextlib import json from pathlib import Path +from typing import TYPE_CHECKING, Annotated import typer from labwire.core.signing import Manifest, verify_bundle +if TYPE_CHECKING: + from labwire.core.grants import GrantStore + app = typer.Typer( help="Labwire: open protocol for AI-controlled lab instruments.", no_args_is_help=True, @@ -48,11 +53,140 @@ def verify(bundle: Path) -> None: ) typer.echo(f" command: {manifest.command.name} {json.dumps(manifest.command.params)}") typer.echo(f" status: {manifest.status}") + if manifest.command.safety_class is not None: + typer.echo(f" class: {manifest.command.safety_class}") + if manifest.authorization is not None: + auth = manifest.authorization + if auth.mode == "grant": + use = f", use {auth.use_index}" if auth.use_index is not None else "" + who = f', issued_by "{auth.issued_by}" [unauthenticated note]' if auth.issued_by else "" + typer.echo(f" authorized: grant{use}, request {auth.request_id or '?'}{who}") + else: + typer.echo(f" authorized: {auth.mode}") + # The honesty caveat as a machine-checkable wire fact (SPEC 13.1): + # a v0.3 bundle claiming identity was verified is not a v0.3 bundle. + if manifest.manifest_version == "0.3" and auth.identity_verified: + typer.echo( + "error: identity_verified is true in a 0.3 manifest; v0.3 proves " + "deployment policy and parameter binding, NOT operator identity", + err=True, + ) + raise typer.Exit(1) + typer.echo( + " deployment policy and parameter binding proven; " + "operator identity NOT proven" + ) typer.echo(f" completed: {manifest.timestamps.completed}") if manifest.signer is not None: typer.echo(f" signed by: {manifest.signer.key_id}") +grant_app = typer.Typer( + help="Operator grant store: approve or revoke S3 authorization requests.", + no_args_is_help=True, +) +app.add_typer(grant_app, name="grant") + + +def _store(directory: Path | None) -> "GrantStore": + import os + + from labwire.core.grants import GrantStore + + where = directory or (Path(root) if (root := os.environ.get("LABWIRE_GRANT_STORE")) else None) + if where is None: + typer.echo("error: no grant store; pass --store or set LABWIRE_GRANT_STORE", err=True) + raise typer.Exit(2) + serial = "unknown" + grants_file = where / "grants.json" + if grants_file.exists(): + with contextlib.suppress(ValueError): + serial = ( + json.loads(grants_file.read_text()).get("instrument", {}).get("serial_number") + or serial + ) + pending_file = where / "pending.jsonl" + if serial == "unknown" and pending_file.exists(): + for line in pending_file.read_text().splitlines(): + try: + serial = json.loads(line).get("serial_number") or serial + break + except ValueError: + continue + return GrantStore(where, serial_number=serial) + + +@grant_app.command("list") +def grant_list( + store: Annotated[Path | None, typer.Option("--store", help="Grant store directory")] = None, +) -> None: + """Show pending authorization requests, with their exact parameters. + + This listing is the whole point of the pending-request flow: the + operator reads what was asked from the server's own record, never from + a digest relayed through the agent that wants the approval. + """ + from datetime import UTC, datetime + + grant_store = _store(store) + pending = grant_store.pending(now=datetime.now(UTC)) + if not pending: + typer.echo("no pending authorization requests") + return + for entry in pending: + typer.echo(f"{entry.request_id} {entry.command} S3 requested {entry.requested_at}") + for name, value in sorted(entry.params.items()): + typer.echo(f" {name:8} {json.dumps(value)}") + typer.echo(f" digest {entry.params_digest}") + + +@grant_app.command("approve") +def grant_approve( + request_id: str, + store: Annotated[Path | None, typer.Option("--store", help="Grant store directory")] = None, + ttl: Annotated[str, typer.Option("--ttl", help="Validity window, e.g. 15m, 2h")] = "15m", + uses: Annotated[int, typer.Option("--uses", help="Maximum number of uses")] = 1, + issued_by: Annotated[str | None, typer.Option("--issued-by", help="Free-text label")] = None, + note: Annotated[str | None, typer.Option("--note", help="Free-text note")] = None, +) -> None: + """Approve one pending request, minting a grant bound to its exact parameters.""" + import re + from datetime import UTC, datetime, timedelta + + match = re.fullmatch(r"(\d+)([smh])", ttl) + if match is None: + typer.echo(f"error: --ttl {ttl!r} is not like 15m / 90s / 2h", err=True) + raise typer.Exit(2) + seconds = int(match.group(1)) * {"s": 1, "m": 60, "h": 3600}[match.group(2)] + grant_store = _store(store) + try: + grant = grant_store.approve( + request_id, + now=datetime.now(UTC), + ttl=timedelta(seconds=seconds), + max_uses=uses, + issued_by=issued_by, + note=note, + ) + except KeyError as exc: + typer.echo(f"error: {exc.args[0]}", err=True) + raise typer.Exit(1) from exc + typer.echo(f"grant {grant.grant_id} uses 0/{grant.max_uses} expires {grant.expires_at}") + + +@grant_app.command("revoke") +def grant_revoke( + grant_id: str, + store: Annotated[Path | None, typer.Option("--store", help="Grant store directory")] = None, +) -> None: + """Revoke a grant; presenting it afterwards is refused as revoked.""" + if _store(store).revoke(grant_id): + typer.echo(f"revoked {grant_id}") + else: + typer.echo(f"error: no grant {grant_id!r}", err=True) + raise typer.Exit(1) + + def main() -> None: """Console-script entry point.""" app() diff --git a/packages/core/src/labwire/core/__init__.py b/packages/core/src/labwire/core/__init__.py index 01f64d6..bbc8754 100644 --- a/packages/core/src/labwire/core/__init__.py +++ b/packages/core/src/labwire/core/__init__.py @@ -12,6 +12,7 @@ from labwire.core._meta import PROTOCOL_VERSION, __version__ from labwire.core.capabilities import ( CONFIRMATION_REQUIRED_CLASSES, + KIND_REGISTRY, ChannelSpec, CommandSpec, IdentityInfo, @@ -19,6 +20,7 @@ InterlockSpec, ResourceSpec, SafetyClass, + valid_kind, ) from labwire.core.client import ( CommandHandle, @@ -47,7 +49,8 @@ ValidationError, error_from_wire, ) -from labwire.core.jcs import jcs_canonical, jcs_dumps +from labwire.core.grants import Grant, GrantStore, GrantVerdict +from labwire.core.jcs import jcs_canonical, jcs_dumps, params_digest from labwire.core.messages import ( MESSAGE_TYPES, Authorization, @@ -67,14 +70,18 @@ Clock, CommandContext, Instrument, + InstrumentResource, InstrumentServer, Interlock, + ResourceSnapshot, RunRecord, SystemClock, TelemetryChannel, channel, command, interlock, + resource, + unit_field, ) from labwire.core.session import JsonRpcSession, SessionClosed from labwire.core.signing import ( @@ -90,6 +97,7 @@ __all__ = [ "CONFIRMATION_REQUIRED_CLASSES", + "KIND_REGISTRY", "MANIFEST_VERSION", "MESSAGE_TYPES", "PROTOCOL_VERSION", @@ -109,10 +117,14 @@ "EventNotification", "EventSeverity", "EventStream", + "Grant", + "GrantStore", + "GrantVerdict", "HardwareFaultError", "IdentityInfo", "Instrument", "InstrumentDescriptor", + "InstrumentResource", "InstrumentServer", "Interlock", "InterlockError", @@ -133,6 +145,7 @@ "ResourceIndexEntry", "ResourceReadResult", "ResourceRevision", + "ResourceSnapshot", "ResourceSpec", "RunRecord", "SafetyClass", @@ -158,7 +171,11 @@ "interlock", "jcs_canonical", "jcs_dumps", + "params_digest", + "resource", "sign_manifest", + "unit_field", + "valid_kind", "verify_bundle", "verify_manifest", ] diff --git a/packages/core/src/labwire/core/_meta.py b/packages/core/src/labwire/core/_meta.py index 76f35ea..3221973 100644 --- a/packages/core/src/labwire/core/_meta.py +++ b/packages/core/src/labwire/core/_meta.py @@ -1,5 +1,5 @@ """Version constants shared across modules without import cycles.""" -__version__ = "0.2.1" -PROTOCOL_VERSION = "0.2" -MANIFEST_VERSION = "0.2" +__version__ = "0.3.0.dev0" +PROTOCOL_VERSION = "0.3" +MANIFEST_VERSION = "0.3" diff --git a/packages/core/src/labwire/core/capabilities.py b/packages/core/src/labwire/core/capabilities.py index 2fa27ca..9c244a1 100644 --- a/packages/core/src/labwire/core/capabilities.py +++ b/packages/core/src/labwire/core/capabilities.py @@ -11,6 +11,7 @@ 'SimPump-100' """ +import re from typing import Any, Literal, NamedTuple, Self, cast from pydantic import BaseModel, ConfigDict, model_validator @@ -18,6 +19,45 @@ SafetyClass = Literal["S0", "S1", "S2", "S3"] """Command risk classes (SPEC §8.6), a taxonomy adopted from LAP.""" +KIND_REGISTRY: frozenset[str] = frozenset( + { + "deck", + "labware", + "plate", + "tip_rack", + "trough", + "trash", + "lid", + "container", + "tip_site", + "site", + "consumable", + } +) +"""SPEC Appendix A. Kind names without a dot must come from here; anything +else must be vendor-prefixed (``.``). Seeded from the one +domain that forced the feature, and honest about it.""" + +_RESOURCE_URI = re.compile(r"^labwire:[A-Za-z0-9](?:[A-Za-z0-9_.\-]|%[0-9A-Fa-f]{2})*$") +"""A declared resource URI: ``labwire:`` plus one path segment (SPEC §10.1). + +Items add segments; only single-segment URIs are declarable.""" + +_VENDOR_KIND = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*\.[A-Za-z][A-Za-z0-9_\-]*$") + + +def valid_kind(name: str) -> bool: + """Whether a kind name is registered or well-formed vendor-prefixed. + + Example: + >>> valid_kind("container"), valid_kind("acme.hotel_slot"), valid_kind("wells") + (True, True, False) + """ + if "." in name: + return bool(_VENDOR_KIND.match(name)) + return name in KIND_REGISTRY + + CONFIRMATION_REQUIRED_CLASSES: frozenset[str] = frozenset({"S2", "S3"}) """Classes whose submissions require a confirmation value (SPEC §8.6).""" @@ -60,7 +100,11 @@ class SchemaScan(NamedTuple): ``numeric`` holds a path per place a number may occur. ``opaque`` holds a path per place the schema declines to say, which is treated as a failure rather than as an absence: a schema that permits anything permits a - quantity. + quantity. ``units`` holds, for each numeric path, the value of the + ``unit`` keyword on that node (SPEC §7.6), or None where absent. + ``keyword_nodes`` holds ``(path, node)`` for every node carrying one of + the keywords this protocol claims (``unit``, ``resource_ref``), so + validators can enforce where each is and is not permitted. Example: >>> scan_schema({"properties": {"v": {"type": "number"}}}).numeric @@ -69,6 +113,9 @@ class SchemaScan(NamedTuple): numeric: frozenset[str] opaque: frozenset[str] + units: dict[str, str | None] = {} # noqa: RUF012 - NamedTuple default, never mutated + keyword_nodes: tuple[tuple[str, str, dict[str, Any]], ...] = () + """``(keyword, path, resolved node)`` per claimed-keyword occurrence.""" def _dict(value: Any) -> dict[str, Any] | None: @@ -154,6 +201,9 @@ def _declares_number(schema: dict[str, Any]) -> bool: ) +_CLAIMED_KEYWORDS = ("unit", "resource_ref") + + def _walk( node: Any, root: dict[str, Any], @@ -162,6 +212,8 @@ def _walk( seen: frozenset[str], numeric: set[str], opaque: set[str], + units: dict[str, str | None], + claimed: list[tuple[str, str, dict[str, Any]]], ) -> None: """Record every path at which a number may appear, or which cannot be read.""" if node is False: @@ -181,6 +233,12 @@ def _walk( if _declares_number(resolved): numeric.add(path) + declared_unit = resolved.get("unit") + if path not in units or units[path] is None: + units[path] = declared_unit if isinstance(declared_unit, str) else None + for keyword in _CLAIMED_KEYWORDS: + if keyword in resolved: + claimed.append((keyword, path, resolved)) structural = _CONSTRAINING_KEYS & resolved.keys() if not structural and not _declares_number(resolved): @@ -191,40 +249,52 @@ def _walk( member = resolved.get(key) if isinstance(member, list): for raw in cast("list[Any]", member): - _walk(raw, root, path, depth + 1, seen, numeric, opaque) + _walk(raw, root, path, depth + 1, seen, numeric, opaque, units, claimed) elif member is not None: - _walk(member, root, path, depth + 1, seen, numeric, opaque) + _walk(member, root, path, depth + 1, seen, numeric, opaque, units, claimed) for key in _NAMED_KEYS: members = _dict(resolved.get(key)) if members is not None: for name, member in members.items(): child = f"{path}.{name}" if path else str(name) - _walk(member, root, child, depth + 1, seen, numeric, opaque) + _walk(member, root, child, depth + 1, seen, numeric, opaque, units, claimed) for key in _PATTERN_KEYS: members = _dict(resolved.get(key)) if members is not None: for member in members.values(): - _walk(member, root, f"{path}{{}}", depth + 1, seen, numeric, opaque) + _walk(member, root, f"{path}{{}}", depth + 1, seen, numeric, opaque, units, claimed) prefix_items = resolved.get("prefixItems") if isinstance(prefix_items, list): for index, member in enumerate(cast("list[Any]", prefix_items)): - _walk(member, root, f"{path}[{index}]", depth + 1, seen, numeric, opaque) + _walk( + member, root, f"{path}[{index}]", depth + 1, seen, numeric, opaque, units, claimed + ) for key in _ITEM_KEYS: member = resolved.get(key) if isinstance(member, list): # draft-07 tuple form of `items` for index, entry in enumerate(cast("list[Any]", member)): - _walk(entry, root, f"{path}[{index}]", depth + 1, seen, numeric, opaque) + _walk( + entry, + root, + f"{path}[{index}]", + depth + 1, + seen, + numeric, + opaque, + units, + claimed, + ) elif member is not None: - _walk(member, root, f"{path}[]", depth + 1, seen, numeric, opaque) + _walk(member, root, f"{path}[]", depth + 1, seen, numeric, opaque, units, claimed) for key in _VALUE_KEYS: member = resolved.get(key) if member is not None: - _walk(member, root, f"{path}{{}}", depth + 1, seen, numeric, opaque) + _walk(member, root, f"{path}{{}}", depth + 1, seen, numeric, opaque, units, claimed) # An object that neither names its properties nor closes the door on extra # ones can carry a quantity under a name nobody declared. @@ -250,8 +320,20 @@ def scan_schema(schema: dict[str, Any], root: dict[str, Any] | None = None) -> S """ numeric: set[str] = set() opaque: set[str] = set() - _walk(schema, root if root is not None else schema, "", 0, frozenset(), numeric, opaque) - return SchemaScan(frozenset(numeric), frozenset(opaque)) + units: dict[str, str | None] = {} + claimed: list[tuple[str, str, dict[str, Any]]] = [] + _walk( + schema, + root if root is not None else schema, + "", + 0, + frozenset(), + numeric, + opaque, + units, + claimed, + ) + return SchemaScan(frozenset(numeric), frozenset(opaque), units, tuple(claimed)) def carries_number(schema: dict[str, Any], root: dict[str, Any] | None = None) -> bool: @@ -436,6 +518,72 @@ def _check_schema( f"command {self.name!r}: the {label} carries numbers that name no field, so " "at least one UCUM unit code must be declared" ) + self._check_claimed_keywords(scan, label) + + def _check_claimed_keywords(self, scan: SchemaScan, label: str) -> None: + """Enforce SPEC §7.2/§7.6: where `resource_ref` and `unit` may appear.""" + for keyword, path, node in scan.keyword_nodes: + where = path or "" + if keyword == "unit": + raise ValueError( + f"command {self.name!r}: the 'unit' schema keyword at {where} is " + f"scoped to resource content schemas (SPEC 7.6); {label} units are " + "declared in unit_annotations and returns_units" + ) + if label != "parameter": + raise ValueError( + f"command {self.name!r}: 'resource_ref' at {where} is permitted only " + "inside params_schema in v0.3 (SPEC 7.2)" + ) + ref = _dict(node.get("resource_ref")) + kind = ref.get("kind") if ref else None + enumerated_by = ref.get("enumerated_by") if ref else None + if ( + ref is None + or not isinstance(kind, str) + or not isinstance(enumerated_by, str) + or not kind.strip() + or not enumerated_by.strip() + ): + raise ValueError( + f"command {self.name!r}: resource_ref at {where} must be an object " + "with non-empty string members 'kind' and 'enumerated_by' (SPEC 7.2)" + ) + if not valid_kind(kind): + raise ValueError( + f"command {self.name!r}: resource_ref at {where} declares kind " + f"{kind!r}, which is neither in the kind registry (SPEC Appendix A) " + "nor vendor-prefixed as '.'" + ) + if node.get("type") != "string": + raise ValueError( + f"command {self.name!r}: resource_ref at {where} must sit on a " + "string-typed node; a reference is a URI" + ) + if "pattern" in node: + raise ValueError( + f"command {self.name!r}: resource_ref at {where} must not share its " + "node with a 'pattern': a pattern is satisfiable by invention, which " + "is the failure typed references exist to remove (SPEC 7.2)" + ) + + def references(self) -> list[tuple[str, dict[str, str]]]: + """Every ``(path, resource_ref)`` declared in this command's parameters. + + Example: + >>> CommandSpec( + ... name="go", title="Go", description="Go.", + ... params_schema={"type": "object", "additionalProperties": False}, + ... interruptible=False, + ... ).references() + [] + """ + scan = scan_schema(self.params_schema) + return [ + (path, cast("dict[str, str]", node["resource_ref"])) + for keyword, path, node in scan.keyword_nodes + if keyword == "resource_ref" + ] class ChannelSpec(_SpecModel): @@ -488,6 +636,58 @@ class ResourceSpec(_SpecModel): revision: str content_schema: dict[str, Any] + @model_validator(mode="after") + def _well_formed(self) -> Self: + """Enforce SPEC §7.6: URI shape, kind names, and content units.""" + if not _RESOURCE_URI.match(self.uri): + raise ValueError( + f"resource uri {self.uri!r} is not a declarable labwire: URI: expected " + "'labwire:' plus one path segment, like 'labwire:deck' (items add " + "segments and are not declared)" + ) + for label, name in ( + ("kind", self.kind), + *(("item_kinds entry", k) for k in self.item_kinds), + ): + if not valid_kind(name): + raise ValueError( + f"resource {self.uri!r}: {label} {name!r} is neither in the kind " + "registry (SPEC Appendix A) nor vendor-prefixed as '.'" + ) + if not self.revision.strip(): + raise ValueError(f"resource {self.uri!r}: revision must be non-empty") + scan = scan_schema(self.content_schema) + if scan.opaque: + where = sorted(path or "" for path in scan.opaque) + raise ValueError( + f"resource {self.uri!r}: content_schema does not say what is at {where}; " + "the closed-schema rule of SPEC 7.2 applies to content too" + ) + unitless = sorted( + path or "" + for path in scan.numeric + if not (scan.units.get(path) or "").strip() + ) + if unitless: + raise ValueError( + f"resource {self.uri!r}: numeric content at {unitless} carries no 'unit' " + 'keyword (SPEC 7.6; use "1" for dimensionless). Resource content is ' + "state, state carries quantities, and a units-optional state format " + "would reopen the hole 7.2 closed" + ) + misplaced = [ + (keyword, path) + for keyword, path, _node in scan.keyword_nodes + if keyword == "resource_ref" + ] + if misplaced: + raise ValueError( + f"resource {self.uri!r}: 'resource_ref' is not permitted inside " + f"content_schema (found at {[p or '' for _k, p in misplaced]}); " + "references are a parameter concept (SPEC 7.2)" + ) + return self + class InterlockSpec(_SpecModel): """A declared safety interlock (SPEC §7.4). @@ -526,3 +726,33 @@ class InstrumentDescriptor(_SpecModel): resources: list[ResourceSpec] = [] """REQUIRED of v0.3 servers (SPEC §7.1); tolerated absent on receipt.""" max_concurrent_commands: int = 1 + + @model_validator(mode="after") + def _references_closed(self) -> Self: + """Enforce SPEC §7.6: the reference graph is closed. + + Every ``resource_ref.enumerated_by`` names a declared resource whose + ``item_kinds`` contains the declared kind. Checked here so it holds + both for a server about to serve the descriptor and for a client + that just received one: an agent pointed at a resource that does not + exist has a discovery story that dead-ends. + """ + by_uri = {spec.uri: spec for spec in self.resources} + for command_spec in self.commands: + for path, ref in command_spec.references(): + target = by_uri.get(ref["enumerated_by"]) + if target is None: + raise ValueError( + f"command {command_spec.name!r}: resource_ref at " + f"{path or ''} is enumerated_by " + f"{ref['enumerated_by']!r}, which this descriptor does not " + f"declare (declared: {sorted(by_uri) or '(none)'})" + ) + if ref["kind"] not in target.item_kinds: + raise ValueError( + f"command {command_spec.name!r}: resource_ref at " + f"{path or ''} expects kind {ref['kind']!r}, " + f"which {target.uri!r} does not index " + f"(item_kinds: {target.item_kinds})" + ) + return self diff --git a/packages/core/src/labwire/core/client.py b/packages/core/src/labwire/core/client.py index f294bfb..9ccaf6a 100644 --- a/packages/core/src/labwire/core/client.py +++ b/packages/core/src/labwire/core/client.py @@ -27,6 +27,7 @@ EventNotification, InitializeResult, PeerInfo, + ResourceReadResult, ServerCapabilities, SubmitResult, SubscribeResult, @@ -333,13 +334,20 @@ async def describe(self) -> InstrumentDescriptor: return InstrumentDescriptor.model_validate(raw) async def submit( - self, command: str, params: dict[str, Any], *, confirmation: str | None = None + self, + command: str, + params: dict[str, Any], + *, + confirmation: str | None = None, + authorization: str | None = None, + if_revision: dict[str, str] | None = None, ) -> CommandHandle: """Submit a command and return a handle to its run (SPEC §8.2). - ``confirmation`` is REQUIRED for commands whose ``safety_class`` is - ``S2`` or ``S3`` (SPEC §8.6); omitting it raises - :class:`ConfirmationRequiredError`. + ``confirmation`` is REQUIRED for ``S2`` commands, and + ``authorization`` (an operator grant id) for ``S3`` (SPEC §8.6); a + confirmation never satisfies ``S3``. ``if_revision`` maps resource + URIs to the revisions the plan was made against (SPEC §10.5). Example: >>> # handle = await client.submit("dispense", {"volume_ul": 500.0}, @@ -348,12 +356,26 @@ async def submit( payload: dict[str, Any] = {"command": command, "params": params} if confirmation is not None: payload["confirmation"] = confirmation + if authorization is not None: + payload["authorization"] = {"grant_id": authorization} + if if_revision: + payload["if_revision"] = dict(if_revision) raw = await self._request("command/submit", payload) result = SubmitResult.model_validate(raw) handle = CommandHandle(self, result.command_id) self._handles[result.command_id] = handle return handle + async def read_resource(self, uri: str) -> ResourceReadResult: + """Read a declared resource: index, content, and revision (SPEC §10.2). + + Example: + >>> # deck = await client.read_resource("labwire:deck") + >>> # deck.revision + """ + raw = await self._request("resource/read", {"uri": uri}) + return ResourceReadResult.model_validate(raw) + def telemetry( self, channels: list[str], *, max_rate_hz: float | None = None ) -> TelemetrySubscription: diff --git a/packages/core/src/labwire/core/grants.py b/packages/core/src/labwire/core/grants.py new file mode 100644 index 0000000..03183c2 --- /dev/null +++ b/packages/core/src/labwire/core/grants.py @@ -0,0 +1,340 @@ +"""The operator grant store (SPEC §8.6): S3 authorization an agent cannot mint. + +A grant is a record in a directory the server reads and an operator tool +writes. The protocol has no method that touches this store, which is the +entire point: whatever an agent can do over the wire, minting authorization +is not part of it. + +Three files, with deliberately separated writers: + +- ``grants.json``: written only by the operator tool (``labwire grant``). + The server reads it, re-reading on mtime change, and never writes it. +- ``pending.jsonl``: written only by the server. A refused S3 submission + records what was asked, so the operator's approval tool reads the real + command and parameters from the server's own record, never from a digest + relayed through the agent that wants the approval. +- ``uses.json``: written only by the server. Use counts live here rather + than in ``grants.json`` so neither writer ever touches the other's file. + +Example: + >>> # store = GrantStore(Path(os.environ["LABWIRE_GRANT_STORE"])) +""" + +import contextlib +import json +import os +import secrets +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Literal, cast + +GrantRefusal = Literal[ + "absent", + "unsupported_scheme", + "unknown", + "command_mismatch", + "params_mismatch", + "instrument_mismatch", + "not_yet_valid", + "expired", + "exhausted", + "revoked", +] +"""SPEC §8.6 refusal reasons, in the order they are checked.""" + +PENDING_CAP = 64 +PENDING_TTL = timedelta(minutes=15) + + +def _parse_when(text: str) -> datetime | None: + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + + +@dataclass(frozen=True) +class Grant: + """One provisioned grant, as read from ``grants.json``. + + Example: + >>> # grant.max_uses + """ + + grant_id: str + command: str + params_digest: str + not_before: str + expires_at: str + max_uses: int + revoked: bool = False + request_id: str | None = None + issued_by: str | None = None + note: str | None = None + + +@dataclass(frozen=True) +class GrantVerdict: + """The outcome of presenting a grant id for one submission. + + ``use_index`` is 1-based and set only on success, for the manifest. + + Example: + >>> # verdict.reason + """ + + ok: bool + reason: GrantRefusal | None = None + grant: Grant | None = None + use_index: int | None = None + + +@dataclass +class PendingRequest: + """A refused S3 submission, recorded for the operator to inspect. + + Example: + >>> # pending.request_id + """ + + request_id: str + command: str + params: dict[str, Any] + params_digest: str + serial_number: str + requested_at: str + expires_at: str + + +class GrantStore: + """File-backed grant store; see the module docstring for the layout. + + Verification and consumption are synchronous and hold no awaits, so a + caller that checks and consumes without yielding cannot race another + submission in the same event loop; cross-process safety comes from the + use file being written before the run is created. + + Example: + >>> # GrantStore(Path("/etc/labwire/grants"), serial_number="rig-1") + """ + + def __init__(self, directory: Path, *, serial_number: str) -> None: + self.directory = Path(directory) + self.serial_number = serial_number + self.directory.mkdir(parents=True, exist_ok=True) + self._grants_path = self.directory / "grants.json" + self._pending_path = self.directory / "pending.jsonl" + self._uses_path = self.directory / "uses.json" + self._grants: dict[str, Grant] = {} + self._grants_mtime: float | None = None + self._store_serial: str | None = None + + # -- grants.json (operator-written; server read-only) -------------------- + + def _refresh(self) -> None: + try: + mtime = self._grants_path.stat().st_mtime + except FileNotFoundError: + self._grants, self._grants_mtime, self._store_serial = {}, None, None + return + if mtime == self._grants_mtime: + return + raw = cast("dict[str, Any]", json.loads(self._grants_path.read_text() or "{}")) + instrument = cast("dict[str, Any]", raw.get("instrument") or {}) + self._store_serial = cast("str | None", instrument.get("serial_number")) + loaded: dict[str, Grant] = {} + for entry in cast("list[dict[str, Any]]", raw.get("grants", [])): + grant = Grant( + grant_id=str(entry["grant_id"]), + command=str(entry["command"]), + params_digest=str(entry["params_digest"]), + not_before=str(entry.get("not_before", "1970-01-01T00:00:00Z")), + expires_at=str(entry["expires_at"]), + max_uses=int(entry.get("max_uses", 1)), + revoked=bool(entry.get("revoked", False)), + request_id=entry.get("request_id"), + issued_by=entry.get("issued_by"), + note=entry.get("note"), + ) + loaded[grant.grant_id] = grant + self._grants = loaded + self._grants_mtime = mtime + + def _uses(self) -> dict[str, int]: + try: + return {str(k): int(v) for k, v in json.loads(self._uses_path.read_text()).items()} + except (FileNotFoundError, ValueError): + return {} + + def verify_and_consume( + self, *, grant_id: str, command: str, params_digest: str, now: datetime + ) -> GrantVerdict: + """Check a grant against one submission and, if valid, spend one use. + + The check-and-consume is synchronous with no yields; the use count is + persisted **before** success is reported, so a spent single-use grant + stays spent across a restart. + + Example: + >>> # store.verify_and_consume(grant_id="g-1", command="move_plate", + >>> # params_digest="sha256:...", now=clock.now()) + """ + self._refresh() + grant = self._grants.get(grant_id) + if grant is None: + return GrantVerdict(False, "unknown") + if grant.revoked: + return GrantVerdict(False, "revoked") + if grant.command != command: + return GrantVerdict(False, "command_mismatch") + if grant.params_digest != params_digest: + return GrantVerdict(False, "params_mismatch") + if self._store_serial is not None and self._store_serial != self.serial_number: + return GrantVerdict(False, "instrument_mismatch") + not_before = _parse_when(grant.not_before) + expires_at = _parse_when(grant.expires_at) + if not_before is not None and now < not_before: + return GrantVerdict(False, "not_yet_valid") + if expires_at is None or now >= expires_at: + return GrantVerdict(False, "expired") + uses = self._uses() + used = uses.get(grant_id, 0) + if used >= grant.max_uses: + return GrantVerdict(False, "exhausted") + uses[grant_id] = used + 1 + self._write_atomic(self._uses_path, json.dumps(uses, indent=2) + "\n") + return GrantVerdict(True, None, grant, used + 1) + + # -- pending.jsonl (server-written) --------------------------------------- + + def record_pending( + self, *, command: str, params: dict[str, Any], params_digest: str, now: datetime + ) -> PendingRequest: + """Record a refused S3 submission for the operator to inspect. + + Capped and expiring, so an agent cannot fill a disk by asking. + + Example: + >>> # store.record_pending(command="move_plate", params={...}, + >>> # params_digest="sha256:...", now=clock.now()) + """ + pending = PendingRequest( + request_id=f"req-{secrets.token_hex(4)}", + command=command, + params=params, + params_digest=params_digest, + serial_number=self.serial_number, + requested_at=now.astimezone(UTC).isoformat().replace("+00:00", "Z"), + expires_at=(now + PENDING_TTL).astimezone(UTC).isoformat().replace("+00:00", "Z"), + ) + alive = [ + entry + for entry in self.pending(now=now) + if entry.params_digest != params_digest or entry.command != command + ][-(PENDING_CAP - 1) :] + alive.append(pending) + lines = "".join(json.dumps(vars(entry), sort_keys=True) + "\n" for entry in alive) + self._write_atomic(self._pending_path, lines) + return pending + + def pending(self, *, now: datetime) -> list[PendingRequest]: + """Unexpired pending requests, oldest first. + + Example: + >>> # store.pending(now=datetime.now(UTC)) + """ + try: + lines = self._pending_path.read_text().splitlines() + except FileNotFoundError: + return [] + alive: list[PendingRequest] = [] + for line in lines: + try: + raw = json.loads(line) + except ValueError: + continue + raw.pop("extra", None) + entry = PendingRequest(**raw) + expires = _parse_when(entry.expires_at) + if expires is not None and now < expires: + alive.append(entry) + return alive + + def find_pending(self, request_id: str, *, now: datetime) -> PendingRequest | None: + """Look up one unexpired pending request by id. + + Example: + >>> # store.find_pending("req-3f1c8d9e", now=datetime.now(UTC)) + """ + for entry in self.pending(now=now): + if entry.request_id == request_id: + return entry + return None + + # -- operator side (used by the CLI, never by the server) ----------------- + + def approve( + self, + request_id: str, + *, + now: datetime, + ttl: timedelta, + max_uses: int, + issued_by: str | None = None, + note: str | None = None, + ) -> Grant: + """Turn a pending request into a grant. Operator-tool code path. + + Example: + >>> # store.approve("req-3f1c8d9e", now=..., ttl=timedelta(minutes=15), + >>> # max_uses=1) + """ + pending = self.find_pending(request_id, now=now) + if pending is None: + raise KeyError(f"no unexpired pending request {request_id!r}") + grant = Grant( + grant_id=f"g-{secrets.token_hex(16)}", + command=pending.command, + params_digest=pending.params_digest, + not_before=now.astimezone(UTC).isoformat().replace("+00:00", "Z"), + expires_at=(now + ttl).astimezone(UTC).isoformat().replace("+00:00", "Z"), + max_uses=max_uses, + request_id=request_id, + issued_by=issued_by, + note=note, + ) + raw: dict[str, Any] = {"version": 1, "instrument": {"serial_number": self.serial_number}} + with contextlib.suppress(FileNotFoundError, ValueError): + raw = json.loads(self._grants_path.read_text()) + raw.setdefault("grants", []).append( + {k: v for k, v in vars(grant).items() if v is not None and k != "revoked"} + ) + self._write_atomic(self._grants_path, json.dumps(raw, indent=2) + "\n") + return grant + + def revoke(self, grant_id: str) -> bool: + """Mark a grant revoked. Operator-tool code path. + + Example: + >>> # store.revoke("g-7f2a91c4") + """ + try: + raw = json.loads(self._grants_path.read_text()) + except (FileNotFoundError, ValueError): + return False + found = False + for entry in cast("list[dict[str, Any]]", raw.get("grants", [])): + if entry.get("grant_id") == grant_id: + entry["revoked"] = True + found = True + if found: + self._write_atomic(self._grants_path, json.dumps(raw, indent=2) + "\n") + return found + + @staticmethod + def _write_atomic(path: Path, text: str) -> None: + scratch = path.with_suffix(path.suffix + f".tmp-{uuid.uuid4().hex[:8]}") + scratch.write_text(text) + os.replace(scratch, path) diff --git a/packages/core/src/labwire/core/jcs.py b/packages/core/src/labwire/core/jcs.py index 4b74318..bd5a876 100644 --- a/packages/core/src/labwire/core/jcs.py +++ b/packages/core/src/labwire/core/jcs.py @@ -85,3 +85,20 @@ def jcs_canonical(value: Any) -> bytes: b'{"v":1}' """ return jcs_dumps(value).encode() + + +def params_digest(params: "dict[str, Any]") -> str: + """The SPEC §8.6 parameter digest: sha256 over RFC 8785 canonical JSON. + + Computed over the normalized parameter object of SPEC §8.2, so the + digested thing and the recorded thing cannot disagree, and an auditor + can recompute it offline from a manifest. The binding of an operator + authorization to this digest is LAP's design, adopted with credit. + + Example: + >>> params_digest({}) + 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + """ + import hashlib + + return "sha256:" + hashlib.sha256(jcs_canonical(params)).hexdigest() diff --git a/packages/core/src/labwire/core/server.py b/packages/core/src/labwire/core/server.py index 3cc1403..d5f5627 100644 --- a/packages/core/src/labwire/core/server.py +++ b/packages/core/src/labwire/core/server.py @@ -22,12 +22,14 @@ import asyncio import copy +import difflib import hashlib import inspect import itertools import json import logging import math +import os import uuid from collections.abc import Awaitable, Callable from datetime import UTC, datetime @@ -36,15 +38,16 @@ from labwire.core._meta import PROTOCOL_VERSION, __version__ from labwire.core.capabilities import ( - CONFIRMATION_REQUIRED_CLASSES, ChannelSpec, CommandSpec, IdentityInfo, InstrumentDescriptor, InterlockSpec, + ResourceSpec, SafetyClass, ) from labwire.core.errors import ( + AuthorizationRequiredError, BusyError, CanceledError, ConfirmationRequiredError, @@ -55,10 +58,13 @@ LabwireError, MethodNotFoundError, NotCancelableError, + StaleRevisionError, + UnknownReferenceError, UnsupportedError, ValidationError, ) -from labwire.core.jcs import jcs_canonical +from labwire.core.grants import GrantStore, GrantVerdict +from labwire.core.jcs import jcs_canonical, params_digest from labwire.core.messages import ( TERMINAL_STATES, CommandIdParams, @@ -69,6 +75,10 @@ InitializeResult, PeerInfo, Progress, + ResourceIndexEntry, + ResourceReadParams, + ResourceReadResult, + ResourceRevision, ServerCapabilities, SubmitParams, SubscribeParams, @@ -81,6 +91,7 @@ from pydantic import ( BaseModel, ConfigDict, + Field, TypeAdapter, create_model, ) @@ -337,6 +348,213 @@ async def sleep(self, seconds: float) -> None: await self._clock.sleep(seconds) +class ResourceSnapshot: + """One read of a resource: its index and its content, together. + + A reader returns both at once so revision derivation covers exactly what + a client would see, and index and content cannot disagree about a moment. + + Example: + >>> snap = ResourceSnapshot(index=[], content=None) + >>> snap.index + [] + """ + + def __init__(self, *, index: list[ResourceIndexEntry], content: Any) -> None: + self.index = index + self.content = content + + +class InstrumentResource: + """A declared resource (SPEC §7.6); created with :func:`resource`. + + Each :class:`Instrument` instance gets its own copy, like channels. The + revision is derived from the canonicalized read result, so a driver + cannot forget to bump it; :meth:`touch` recomputes it and emits the + reserved ``resource/changed`` event when it moved. + + Example: + >>> # deck = resource("labwire:deck", kind="deck", ...) + >>> # @deck.reader + >>> # def _read_deck(self) -> ResourceSnapshot: ... + """ + + def __init__( + self, + uri: str, + *, + kind: str, + title: str, + description: str, + content_model: type[BaseModel], + item_kinds: list[str], + ) -> None: + self.uri = uri + self.kind = kind + self.title = title + self.description = description + self.content_model = content_model + self.item_kinds = list(item_kinds) + self._reader_name: str | None = None + self._owner: Any = None + self._on_changed: Callable[[str, str], None] | None = None + self._last_revision: str | None = None + # Validate the declaration now, so a bad content model fails at class + # definition time with the same discipline as @command. + self.content_schema = content_model.model_json_schema(mode="serialization") + self.content_schema.pop("title", None) + try: + self.spec_template = ResourceSpec( + uri=uri, + kind=kind, + title=title, + description=description, + item_kinds=self.item_kinds, + revision="unread", + content_schema=self.content_schema, + ) + except PydanticValidationError as exc: + reasons = "; ".join( + str(error["msg"]).removeprefix("Value error, ") for error in exc.errors() + ) + raise TypeError(f"invalid resource declaration {uri!r}: {reasons}") from exc + + def reader(self, fn: Callable[[Any], ResourceSnapshot]) -> Callable[[Any], ResourceSnapshot]: + """Register the method that produces this resource's snapshot. + + Example: + >>> # @deck.reader + >>> # def _read_deck(self) -> ResourceSnapshot: ... + """ + self._reader_name = fn.__name__ + return fn + + def _bound(self) -> ResourceSnapshot: + if self._reader_name is None or self._owner is None: + raise TypeError(f"resource {self.uri!r} has no @reader method") + snapshot = getattr(self._owner, self._reader_name)() + if not isinstance(snapshot, ResourceSnapshot): + raise TypeError( + f"resource {self.uri!r}: reader must return a ResourceSnapshot, " + f"got {type(snapshot).__name__}" + ) + return snapshot + + def read(self, clock: Clock | None = None) -> ResourceReadResult: + """Read the resource: index, content, and the derived revision. + + Example: + >>> # instrument.deck.read() + """ + snapshot = self._bound() + content = _jsonable(snapshot.content) + index = [entry.model_dump(mode="json", exclude_none=True) for entry in snapshot.index] + revision = self._derive_revision(index, content) + self._last_revision = revision + moment = (clock or SystemClock()).now() + return ResourceReadResult( + uri=self.uri, + kind=self.kind, + revision=revision, + read_at=rfc3339(moment), + index_complete=True, + index=[ResourceIndexEntry.model_validate(entry) for entry in index], + content=content, + ) + + def _derive_revision(self, index: list[dict[str, Any]], content: Any) -> str: + digest = hashlib.sha256(jcs_canonical({"index": index, "content": content})).hexdigest() + return digest[:16] + + def revision(self) -> str: + """The current derived revision (reads the resource). + + Example: + >>> # instrument.deck.revision() + """ + snapshot = self._bound() + content = _jsonable(snapshot.content) + index = [entry.model_dump(mode="json", exclude_none=True) for entry in snapshot.index] + return self._derive_revision(index, content) + + def touch(self) -> None: + """Recompute the revision and emit ``resource/changed`` if it moved. + + Drivers call this after anything that may have changed state; a + touch that changed nothing emits nothing, so calling it liberally + is safe. + + Example: + >>> # self.deck.touch() + """ + previous = self._last_revision + current = self.revision() + self._last_revision = current + if current != previous and self._on_changed is not None: + self._on_changed(self.uri, current) + + def spec(self) -> ResourceSpec: + """The declaration for the descriptor, with a current revision. + + Example: + >>> # instrument.deck.spec().kind + """ + return ResourceSpec( + uri=self.uri, + kind=self.kind, + title=self.title, + description=self.description, + item_kinds=self.item_kinds, + revision=self.revision(), + content_schema=self.content_schema, + ) + + +def resource( + uri: str, + *, + kind: str, + title: str, + description: str, + content_model: type[BaseModel], + item_kinds: list[str] | None = None, +) -> InstrumentResource: + """Declare a resource at class scope (SPEC §7.6), like a channel. + + ``content_model`` is a pydantic model whose serialization schema becomes + ``content_schema``; its numeric fields must carry ``unit`` keywords via + ``json_schema_extra`` (see :func:`unit_field`). The declaration is + validated immediately, with the same import-time discipline as + :func:`command`. + + Example: + >>> # deck = resource("labwire:deck", kind="deck", title="Deck", + >>> # description="...", content_model=DeckState, + >>> # item_kinds=["labware", "container"]) + """ + return InstrumentResource( + uri, + kind=kind, + title=title, + description=description, + content_model=content_model, + item_kinds=item_kinds or [], + ) + + +def unit_field(unit_code: str, **kwargs: Any) -> Any: + """A pydantic ``Field`` whose schema carries the SPEC §7.6 unit keyword. + + Example: + >>> from pydantic import BaseModel + >>> class Syringe(BaseModel): + ... capacity_ul: float = unit_field("uL") + """ + extra = dict(kwargs.pop("json_schema_extra", None) or {}) + extra["unit"] = unit_code + return Field(json_schema_extra=extra, **kwargs) + + def _jsonable(value: Any) -> Any: """Normalize a handler's return value to plain JSON types. @@ -490,6 +708,7 @@ class Instrument: def __init__(self) -> None: self._channels: dict[str, TelemetryChannel] = {} self._interlocks: dict[str, Interlock] = {} + self._resources: dict[str, InstrumentResource] = {} for klass in reversed(type(self).__mro__): for attr_name, attr in vars(klass).items(): if isinstance(attr, TelemetryChannel): @@ -502,6 +721,13 @@ def __init__(self) -> None: mine._on_change = None # pyright: ignore[reportPrivateUsage] setattr(self, attr_name, mine) self._interlocks[mine.name] = mine + elif isinstance(attr, InstrumentResource): + own = copy.copy(attr) + own._owner = self # pyright: ignore[reportPrivateUsage] + own._on_changed = None # pyright: ignore[reportPrivateUsage] + own._last_revision = None # pyright: ignore[reportPrivateUsage] + setattr(self, attr_name, own) + self._resources[own.uri] = own def commands(self) -> dict[str, CommandMeta]: """Return the declared commands, by name.""" @@ -524,6 +750,7 @@ def describe(self) -> InstrumentDescriptor: commands=[meta.spec for meta in self.commands().values()], channels=[ch.spec for ch in self._channels.values()], interlocks=[lock.spec() for lock in self._interlocks.values()], + resources=[res.spec() for res in self._resources.values()], max_concurrent_commands=self.max_concurrent_commands, ) @@ -599,6 +826,9 @@ def __init__( self.hasher = hashlib.sha256() self.channels: set[str] = set() self.record_lines: list[bytes] | None = None # retained iff manifests enabled + self.authorization: GrantVerdict | None = None + self.revisions_at_start: dict[str, str] = {} + self.revisions_at_end: dict[str, str] = {} def add_record(self, canonical: bytes) -> None: line = canonical + b"\n" @@ -616,12 +846,18 @@ def is_canceling(self) -> bool: return self.status == "canceling" def snapshot(self) -> dict[str, Any]: + changed = [ + ResourceRevision(uri=uri, revision=self.revisions_at_end[uri]) + for uri in sorted(self.revisions_at_end) + if self.revisions_at_end[uri] != self.revisions_at_start.get(uri) + ] status = CommandStatus( command_id=self.run_id, status=self.status, progress=self.progress, result=self.result, error=self.error, + resource_revisions=changed or None, ) return status.model_dump(mode="json", exclude_none=True) @@ -710,11 +946,37 @@ def __init__( manifest_dir: Path | str | None = None, signing_key: SigningKey | None = None, confirmation_token: str | None = None, + grant_store: Path | str | GrantStore | None = None, ) -> None: self.instrument = instrument self.clock: Clock = clock if clock is not None else SystemClock() self.server_name = server_name self._confirmation_token = confirmation_token + if isinstance(grant_store, GrantStore): + self._grant_store: GrantStore | None = grant_store + elif grant_store is not None: + self._grant_store = GrantStore( + Path(grant_store), serial_number=instrument.identity.serial_number + ) + else: + env = os.environ.get("LABWIRE_GRANT_STORE") + self._grant_store = ( + GrantStore(Path(env), serial_number=instrument.identity.serial_number) + if env + else None + ) + declared_s3 = [ + meta.spec.name + for meta in instrument.commands().values() + if meta.spec.safety_class == "S3" + ] + if declared_s3 and self._grant_store is None: + # SPEC §6.1: hazardous commands with no way to authorize them is a + # misconfiguration, not permissiveness. + raise TypeError( + f"instrument declares S3 command(s) {sorted(declared_s3)} but no grant " + "store is configured; pass grant_store= or set LABWIRE_GRANT_STORE" + ) self._manifest_dir = Path(manifest_dir) if manifest_dir is not None else None self._signing_key = signing_key if self._manifest_dir is not None and self._signing_key is None: @@ -731,6 +993,8 @@ def __init__( ch._clock = self.clock # pyright: ignore[reportPrivateUsage] for lock in instrument._interlocks.values(): # pyright: ignore[reportPrivateUsage] lock._on_change = self._interlock_changed # pyright: ignore[reportPrivateUsage] + for declared in instrument._resources.values(): # pyright: ignore[reportPrivateUsage] + declared._on_changed = self._resource_changed # pyright: ignore[reportPrivateUsage] # -- wiring --------------------------------------------------------------- @@ -851,6 +1115,8 @@ async def _dispatch(self, session: _ServerSession, method: str, params: dict[str match method: case "instrument/describe": return self.instrument.describe().model_dump(mode="json", exclude_none=True) + case "resource/read": + return self._read_resource(params) case "command/submit": return await self._submit(session, params) case "command/status": @@ -874,7 +1140,11 @@ async def _initialize(self, session: _ServerSession, params: dict[str, Any]) -> protocol_version=PROTOCOL_VERSION, server_info=PeerInfo(name=self.server_name, version=__version__), capabilities=ServerCapabilities( - telemetry=True, events=True, manifests=self._manifest_dir is not None + telemetry=True, + events=True, + manifests=self._manifest_dir is not None, + resources=bool(self.instrument._resources), # pyright: ignore[reportPrivateUsage] + grants=self._grant_store is not None, ), ) return result.model_dump(mode="json") @@ -895,9 +1165,218 @@ def _validate[M: BaseModel](self, model: type[M], params: dict[str, Any]) -> M: def _active_runs(self) -> list[_Run]: return [run for run in self._runs.values() if run.active] + def _collect_references( + self, meta: "CommandMeta", normalized: dict[str, Any] + ) -> list[tuple[str, str, dict[str, str]]]: + """``(pointer, value, resource_ref)`` for every reference in a submission. + + Walks the schema and the instance together, so the second element of + an array is nameable by RFC 6901 pointer in the refusal. + """ + found: list[tuple[str, str, dict[str, str]]] = [] + + def walk(node: dict[str, Any], instance: Any, pointer: str) -> None: + ref = node.get("resource_ref") + if isinstance(ref, dict) and isinstance(instance, str): + found.append((pointer, instance, cast("dict[str, str]", ref))) + return + reference = node.get("$ref") + if isinstance(reference, str) and reference.startswith("#/$defs/"): + definitions = cast("dict[str, Any]", meta.spec.params_schema.get("$defs") or {}) + target = definitions.get(reference.removeprefix("#/$defs/")) + if isinstance(target, dict): + walk(cast("dict[str, Any]", target), instance, pointer) + return + for combinator in ("anyOf", "oneOf", "allOf"): + variants = node.get(combinator) + if isinstance(variants, list): + for variant in cast("list[Any]", variants): + if isinstance(variant, dict): + walk(cast("dict[str, Any]", variant), instance, pointer) + properties = node.get("properties") + if isinstance(properties, dict) and isinstance(instance, dict): + for name, member in cast("dict[str, Any]", properties).items(): + if isinstance(member, dict) and name in instance: + walk( + cast("dict[str, Any]", member), + cast("dict[str, Any]", instance)[name], + f"{pointer}/{name}", + ) + items = node.get("items") + if isinstance(items, dict) and isinstance(instance, list): + for index, element in enumerate(cast("list[Any]", instance)): + walk(cast("dict[str, Any]", items), element, f"{pointer}/{index}") + prefix_items = node.get("prefixItems") + if isinstance(prefix_items, list) and isinstance(instance, list): + for index, (member, element) in enumerate( + zip(cast("list[Any]", prefix_items), cast("list[Any]", instance), strict=False) + ): + if isinstance(member, dict): + walk(cast("dict[str, Any]", member), element, f"{pointer}/{index}") + + walk(meta.spec.params_schema, normalized, "") + return found + + def _resolve_references(self, meta: "CommandMeta", normalized: dict[str, Any]) -> None: + """SPEC §10.4: every reference resolves against a fresh read, or refuse.""" + reads: dict[str, ResourceReadResult] = {} + for pointer, value, ref in self._collect_references(meta, normalized): + enumerated_by = ref["enumerated_by"] + expected_kind = ref["kind"] + source = self.instrument._resources.get(enumerated_by) # pyright: ignore[reportPrivateUsage] + if source is None: # closure makes this unreachable when served + raise UnknownReferenceError( + f"parameter {pointer}: enumerating resource {enumerated_by!r} is not " + "declared by this instrument", + details={"pointer": pointer, "reference": value, "reason": "unknown_resource"}, + ) + if enumerated_by not in reads: + reads[enumerated_by] = source.read(self.clock) + snapshot = reads[enumerated_by] + resolved_kinds, resolved_prefix, reason = self._resolve_one(snapshot, value) + if resolved_kinds is not None and expected_kind in resolved_kinds: + continue + if resolved_kinds is not None: + reason = "kind_mismatch" + parameter = pointer.strip("/").split("/", 1)[0] + candidates = [entry.uri for entry in snapshot.index if expected_kind in entry.kinds] + [ + f"{entry.uri}/{item_id}" + for entry in snapshot.index + if entry.children is not None and expected_kind in entry.children.kinds + for item_id in entry.children.ids + ] + close = difflib.get_close_matches(value, candidates, n=5, cutoff=0.4) + details: dict[str, Any] = { + "pointer": pointer, + "parameter": parameter, + "reference": value, + "expected_kind": expected_kind, + "enumerated_by": enumerated_by, + "reason": reason, + "read": {"method": "resource/read", "params": {"uri": enumerated_by}}, + } + if resolved_prefix is not None: + details["resolved_prefix"] = resolved_prefix + if resolved_kinds is not None: + details["resolved_kinds"] = list(resolved_kinds) + if close: + details["did_you_mean"] = close + article = "a" if expected_kind[0] not in "aeiou" else "an" + raise UnknownReferenceError( + f"parameter {pointer}: {value!r} is not {article} {expected_kind} on this " + "instrument", + details=details, + ) + + @staticmethod + def _resolve_one( + snapshot: ResourceReadResult, value: str + ) -> tuple[list[str] | None, str | None, str]: + """Resolve one reference per SPEC §10.2: kinds, longest prefix, reason.""" + if not value.startswith("labwire:") or value.endswith("/") or "//" in value: + return None, None, "malformed_uri" + prefix: str | None = None + for entry in snapshot.index: + if entry.uri == value: + return list(entry.kinds), entry.uri, "no_such_item" + if value.startswith(entry.uri + "/"): + prefix = entry.uri + item_id = value.removeprefix(entry.uri + "/") + if entry.children is not None and item_id in entry.children.ids: + return list(entry.children.kinds), entry.uri, "no_such_item" + if prefix is not None: + return None, prefix, "no_such_item" + return None, None, "unknown_resource" + + def _check_revisions(self, if_revision: dict[str, str]) -> None: + """SPEC §10.5: refuse a stale plan before anything is spent.""" + for uri, submitted in if_revision.items(): + declared = self.instrument._resources.get(uri) # pyright: ignore[reportPrivateUsage] + if declared is None: + raise UnknownReferenceError( + f"if_revision names {uri!r}, which this instrument does not declare", + details={"reference": uri, "reason": "unknown_resource"}, + ) + current = declared.revision() + if current != submitted: + raise StaleRevisionError( + f"{uri} has moved since this plan was made", + details={ + "uri": uri, + "submitted_revision": submitted, + "current_revision": current, + "read": {"method": "resource/read", "params": {"uri": uri}}, + }, + ) + + def _authorize_s3( + self, meta: "CommandMeta", submit: SubmitParams, normalized: dict[str, Any] + ) -> GrantVerdict: + """SPEC §8.6: verify and atomically consume a grant, or refuse.""" + digest = params_digest(normalized) + if self._grant_store is None: # pragma: no cover - refused at construction + raise AuthorizationRequiredError( + f"command {submit.command!r} is S3 and this server holds no grant store", + details={ + "safety_class": "S3", + "command": submit.command, + "reason": "absent", + "mintable_by_agent": False, + }, + ) + if submit.authorization is None: + pending = self._grant_store.record_pending( + command=submit.command, + params=normalized, + params_digest=digest, + now=self.clock.now(), + ) + raise AuthorizationRequiredError( + f"{submit.command} is S3 and requires an operator grant bound to these " + "exact parameters; a confirmation string cannot authorize it", + details={ + "safety_class": "S3", + "command": submit.command, + "reason": "absent", + "request_id": pending.request_id, + "params_digest": digest, + "digest_alg": "sha256", + "canonicalization": "RFC8785", + "mintable_by_agent": False, + "operator_instruction": ( + "On the instrument host run: labwire grant list, then " + f"labwire grant approve {pending.request_id} --ttl 15m --uses 1" + ), + }, + ) + verdict = self._grant_store.verify_and_consume( + grant_id=submit.authorization.grant_id, + command=submit.command, + params_digest=digest, + now=self.clock.now(), + ) + if not verdict.ok: + details: dict[str, Any] = { + "safety_class": "S3", + "command": submit.command, + "reason": verdict.reason, + "params_digest": digest, + "digest_alg": "sha256", + "canonicalization": "RFC8785", + "mintable_by_agent": False, + } + raise AuthorizationRequiredError( + f"the presented grant does not authorize this call: {verdict.reason}", + details=details, + ) + return verdict + async def _submit(self, session: _ServerSession, params: dict[str, Any]) -> dict[str, Any]: # Rejection precedence per SPEC §12.1: unsupported → validation → - # interlock → capacity busy. + # unknown_reference → stale_revision → interlock → capacity busy → + # confirmation / authorization. Everything knowable without an + # operator is checked first, so an agent is never asked to confirm, + # and a single-use grant is never spent, on a call that could not run. submit = self._validate(SubmitParams, params) meta = self.instrument.commands().get(submit.command) if meta is None: @@ -909,14 +1388,12 @@ async def _submit(self, session: _ServerSession, params: dict[str, Any]) -> dict f"params for {submit.command!r} failed validation", details={"errors": _pydantic_error_details(exc)}, ) from exc - if meta.spec.safety_class in CONFIRMATION_REQUIRED_CLASSES and not self._confirmed( - submit.confirmation - ): - raise ConfirmationRequiredError( - f"command {submit.command!r} is {meta.spec.safety_class} and requires " - "an operator confirmation value", - details={"safety_class": meta.spec.safety_class}, - ) + # One object is validated, executed, digested, and recorded (SPEC §8.2): + # the normalized params, with schema defaults applied. + normalized = validated.model_dump(mode="json", by_alias=True) + self._resolve_references(meta, normalized) + if submit.if_revision: + self._check_revisions(submit.if_revision) tripped = { lock.name for lock in self.instrument._interlocks.values() # pyright: ignore[reportPrivateUsage] @@ -935,7 +1412,18 @@ async def _submit(self, session: _ServerSession, params: dict[str, Any]) -> dict raise BusyError( f"at capacity: {self.instrument.max_concurrent_commands} command slot(s) in use" ) - run = _Run(str(uuid.uuid4()), meta, submit.params, session) + authorization: GrantVerdict | None = None + if meta.spec.safety_class == "S3": + # A confirmation MUST NOT satisfy S3, whatever it contains (SPEC §8.6). + authorization = self._authorize_s3(meta, submit, normalized) + elif meta.spec.safety_class == "S2" and not self._confirmed(submit.confirmation): + raise ConfirmationRequiredError( + f"command {submit.command!r} is S2 and requires an operator confirmation value", + details={"safety_class": meta.spec.safety_class}, + ) + run = _Run(str(uuid.uuid4()), meta, normalized, session) + run.authorization = authorization + run.revisions_at_start = self._resource_revisions() if self._manifest_dir is not None: run.record_lines = [] run.timestamps["submitted"] = rfc3339(self.clock.now()) @@ -946,6 +1434,31 @@ async def _submit(self, session: _ServerSession, params: dict[str, Any]) -> dict self._track(run.task) return {"command_id": run.run_id, "status": "accepted"} + def _read_resource(self, params: dict[str, Any]) -> dict[str, Any]: + parsed = self._validate(ResourceReadParams, params) + declared = self.instrument._resources.get(parsed.uri) # pyright: ignore[reportPrivateUsage] + if declared is None: + reason = ( + "malformed_uri" if not parsed.uri.startswith("labwire:") else "unknown_resource" + ) + known = sorted(self.instrument._resources) # pyright: ignore[reportPrivateUsage] + raise UnknownReferenceError( + f"{parsed.uri!r} is not a resource this instrument declares" + + (f"; declared: {', '.join(known)}" if known else ""), + details={"reference": parsed.uri, "reason": reason}, + ) + return declared.read(self.clock).model_dump(mode="json", exclude_none=True) + + def _resource_changed(self, uri: str, revision: str) -> None: + self._emit_event("resource/changed", "info", {"uri": uri, "revision": revision}) + + def _resource_revisions(self) -> dict[str, str]: + """Every declared resource's current revision, for run bracketing.""" + return { + uri: declared.revision() + for uri, declared in self.instrument._resources.items() # pyright: ignore[reportPrivateUsage] + } + def _confirmed(self, confirmation: str | None) -> bool: """Whether a submitted confirmation value is acceptable (SPEC §8.6). @@ -1023,6 +1536,13 @@ def _transition(self, run: _Run, status: CommandState) -> None: def _finish(self, run: _Run, status: CommandState) -> None: run.timestamps["completed"] = rfc3339(self.clock.now()) run.progress = None # progress is a running-state concept (SPEC §8.2) + run.revisions_at_end = self._resource_revisions() + for uri, revision in run.revisions_at_end.items(): + if revision != run.revisions_at_start.get(uri): + declared = self.instrument._resources.get(uri) # pyright: ignore[reportPrivateUsage] + if declared is not None: + declared._last_revision = revision # pyright: ignore[reportPrivateUsage] + self._resource_changed(uri, revision) self._transition(run, status) if self._manifest_dir is not None and self._signing_key is not None: try: @@ -1040,6 +1560,7 @@ def _write_bundle(self, run: _Run) -> None: "name": run.meta.spec.name, "params": run.params, "safety_class": run.meta.spec.safety_class, + "params_digest": params_digest(run.params), }, "status": run.status, "data": { @@ -1053,6 +1574,42 @@ def _write_bundle(self, run: _Run) -> None: manifest["result"] = run.result if run.error is not None: manifest["error"] = run.error.model_dump(mode="json", exclude_none=True) + if run.meta.spec.safety_class == "S2": + manifest["authorization"] = {"mode": "confirmation", "identity_verified": False} + elif run.meta.spec.safety_class == "S3" and run.authorization is not None: + grant = run.authorization.grant + assert grant is not None + block: dict[str, Any] = { + "mode": "grant", + # The id is a bearer value and a signed bundle is durable, so + # only its digest is recorded (SPEC §13.1). + "grant_digest": "sha256:" + hashlib.sha256(grant.grant_id.encode()).hexdigest(), + "expires_at": grant.expires_at, + "use_index": run.authorization.use_index, + "identity_verified": False, + } + for label, value in ( + ("request_id", grant.request_id), + ("issued_by", grant.issued_by), + ("note", grant.note), + ): + if value is not None: + block[label] = value + manifest["authorization"] = block + changed = { + uri: revision + for uri, revision in run.revisions_at_end.items() + if revision != run.revisions_at_start.get(uri) + } + if changed: + manifest["resource_revisions"] = [ + { + "uri": uri, + "revision_at_start": run.revisions_at_start.get(uri, ""), + "revision_at_end": revision, + } + for uri, revision in sorted(changed.items()) + ] assert self._manifest_dir is not None assert self._signing_key is not None doc = sign_manifest(manifest, self._signing_key) diff --git a/packages/core/src/labwire/core/signing.py b/packages/core/src/labwire/core/signing.py index 2db42b6..7d438eb 100644 --- a/packages/core/src/labwire/core/signing.py +++ b/packages/core/src/labwire/core/signing.py @@ -96,11 +96,37 @@ class _M(BaseModel): class ManifestCommand(_M): - """The submitted command, verbatim, and its enforced class (SPEC §13.1).""" + """The run's command: normalized params, class, and digest (SPEC §13.1). + + ``params`` are the normalized parameters from v0.3 on; in 0.2 bundles + they are the raw submission. ``params_digest`` is absent in 0.2 bundles. + """ name: str params: dict[str, Any] safety_class: str | None = None + params_digest: str | None = None + + +class ManifestAuthorization(_M): + """How the run was authorized (SPEC §13.1); absent for S0/S1 and in 0.2.""" + + mode: str + identity_verified: bool + grant_digest: str | None = None + request_id: str | None = None + expires_at: str | None = None + use_index: int | None = None + issued_by: str | None = None + note: str | None = None + + +class ManifestResourceRevision(_M): + """One resource's revision window across the run (SPEC §13.1).""" + + uri: str + revision_at_start: str + revision_at_end: str class ManifestData(_M): @@ -140,6 +166,8 @@ class Manifest(_M): instrument: IdentityInfo command: ManifestCommand status: CommandState + authorization: ManifestAuthorization | None = None + resource_revisions: list[ManifestResourceRevision] | None = None result: Any = None error: JsonRpcError | None = None data: ManifestData diff --git a/packages/core/tests/test_grant_store.py b/packages/core/tests/test_grant_store.py new file mode 100644 index 0000000..da6737e --- /dev/null +++ b/packages/core/tests/test_grant_store.py @@ -0,0 +1,171 @@ +"""The grant store's hard edges: atomicity, persistence, file separation.""" + +import asyncio +import json +import tempfile +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from labwire.core.grants import PENDING_CAP, GrantStore + +NOW = datetime(2026, 7, 27, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def store() -> GrantStore: + return GrantStore(Path(tempfile.mkdtemp(prefix="grants-")), serial_number="rig-1") + + +def _approved(store: GrantStore, *, max_uses: int = 1, command: str = "move_plate") -> str: + pending = store.record_pending( + command=command, params={"to": "labwire:deck/s0"}, params_digest="sha256:aa", now=NOW + ) + grant = store.approve(pending.request_id, now=NOW, ttl=timedelta(minutes=15), max_uses=max_uses) + return grant.grant_id + + +def test_a_spent_single_use_grant_survives_a_restart(store: GrantStore) -> None: + """A restart must not resurrect a spent grant (SPEC 8.6).""" + grant_id = _approved(store) + first = store.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ) + assert first.ok + assert first.use_index == 1 + + reborn = GrantStore(store.directory, serial_number="rig-1") # a fresh process + second = reborn.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ) + assert not second.ok + assert second.reason == "exhausted" + + +async def test_two_concurrent_submits_cannot_both_spend_the_last_use( + store: GrantStore, +) -> None: + """The check-and-consume holds no awaits, so racers serialize.""" + grant_id = _approved(store, max_uses=1) + + async def attempt() -> bool: + return store.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ).ok + + outcomes = await asyncio.gather(*(attempt() for _ in range(8))) + assert outcomes.count(True) == 1 + + +def test_every_refusal_reason_is_reachable(store: GrantStore) -> None: + verify = store.verify_and_consume + assert verify(grant_id="g-none", command="c", params_digest="d", now=NOW).reason == "unknown" + + grant_id = _approved(store, max_uses=2) + assert ( + verify(grant_id=grant_id, command="other", params_digest="sha256:aa", now=NOW).reason + == "command_mismatch" + ) + assert ( + verify(grant_id=grant_id, command="move_plate", params_digest="sha256:bb", now=NOW).reason + == "params_mismatch" + ) + assert ( + verify( + grant_id=grant_id, + command="move_plate", + params_digest="sha256:aa", + now=NOW - timedelta(minutes=1), + ).reason + == "not_yet_valid" + ) + assert ( + verify( + grant_id=grant_id, + command="move_plate", + params_digest="sha256:aa", + now=NOW + timedelta(hours=1), + ).reason + == "expired" + ) + assert verify(grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW).ok + assert verify(grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW).ok + assert ( + verify(grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW).reason + == "exhausted" + ) + + revoked = _approved(store) + assert store.revoke(revoked) + assert ( + verify(grant_id=revoked, command="move_plate", params_digest="sha256:aa", now=NOW).reason + == "revoked" + ) + + +def test_a_grant_for_another_instrument_is_refused(store: GrantStore) -> None: + grant_id = _approved(store) + other = GrantStore(store.directory, serial_number="rig-2") + verdict = other.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ) + assert verdict.reason == "instrument_mismatch" + + +def test_pending_requests_expire_and_are_capped(store: GrantStore) -> None: + for index in range(PENDING_CAP + 20): + store.record_pending( + command="move_plate", + params={"n": index}, + params_digest=f"sha256:{index:02x}", + now=NOW, + ) + assert len(store.pending(now=NOW)) <= PENDING_CAP + assert store.pending(now=NOW + timedelta(hours=1)) == [] # all expired + + +def test_repeating_the_same_refused_call_does_not_multiply_pendings( + store: GrantStore, +) -> None: + """An agent retrying the identical call keeps one pending entry.""" + for _ in range(5): + store.record_pending( + command="move_plate", params={"to": "s0"}, params_digest="sha256:same", now=NOW + ) + matching = [entry for entry in store.pending(now=NOW) if entry.params_digest == "sha256:same"] + assert len(matching) == 1 + + +def test_the_operator_file_and_server_files_are_separate(store: GrantStore) -> None: + """The server never writes grants.json; the operator tool never writes uses.""" + grant_id = _approved(store) + before = (store.directory / "grants.json").read_text() + store.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ) + after = (store.directory / "grants.json").read_text() + assert before == after # consuming touched uses.json, not the operator's file + assert json.loads((store.directory / "uses.json").read_text())[grant_id] == 1 + + +def test_grant_ids_carry_real_entropy(store: GrantStore) -> None: + """A grant id is a bearer value (SPEC 8.6): 128 bits minimum.""" + grant_id = _approved(store) + token = grant_id.removeprefix("g-") + assert len(bytes.fromhex(token)) >= 16 + + +def test_the_store_rereads_when_the_operator_file_changes(store: GrantStore) -> None: + grant_id = _approved(store) + # Simulate the operator revoking from another process: rewrite the file. + raw = json.loads((store.directory / "grants.json").read_text()) + raw["grants"][0]["revoked"] = True + import os + import time + + (store.directory / "grants.json").write_text(json.dumps(raw)) + os.utime(store.directory / "grants.json", (time.time() + 2, time.time() + 2)) + verdict = store.verify_and_consume( + grant_id=grant_id, command="move_plate", params_digest="sha256:aa", now=NOW + ) + assert verdict.reason == "revoked" diff --git a/packages/core/tests/test_resources.py b/packages/core/tests/test_resources.py new file mode 100644 index 0000000..77842f8 --- /dev/null +++ b/packages/core/tests/test_resources.py @@ -0,0 +1,472 @@ +"""Resources and typed references (SPEC §7.6, §10): the v0.3 primitives.""" + +import asyncio +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path +from typing import cast + +import pytest +from labwire.core import ( + AuthorizationRequiredError, + CommandContext, + IdentityInfo, + Instrument, + InstrumentServer, + LabwireClient, + MemoryTransport, + StaleRevisionError, + UnknownReferenceError, + command, +) +from labwire.core.messages import ResourceIndexEntry +from labwire.core.server import ResourceSnapshot, resource, unit_field +from pydantic import BaseModel, ConfigDict + +GRANT = "hotel-operator-grant" + + +class SlotContents(BaseModel): + """One occupied slot of the plate hotel.""" + + model_config = ConfigDict(extra="forbid") + + uri: str + plate_barcode: str + stored_minutes: float = unit_field("min") + + +class HotelState(BaseModel): + """The hotel's content model: occupied slots only, sparse.""" + + model_config = ConfigDict(extra="forbid") + + occupied: list[SlotContents] + capacity: int = unit_field("1") + + +def _hotel_ref(kind: str) -> type[str]: + from typing import Annotated + + from pydantic import Field + + return Annotated[ # pyright: ignore[reportReturnType] + str, + Field(json_schema_extra={"resource_ref": {"kind": kind, "enumerated_by": "labwire:hotel"}}), + ] + + +Slot = _hotel_ref("site") + + +class PlateHotel(Instrument): + """A storage hotel: slots are sites, and store/retrieve reference them.""" + + identity = IdentityInfo( + manufacturer="Labwire Project", + model="HotelRig-1", + serial_number="SIM-0051", + firmware_version="0.3.0", + ) + + hotel = resource( + "labwire:hotel", + kind="deck", + title="Hotel", + description="Slot occupancy. Every slot a command can name is in this index.", + content_model=HotelState, + item_kinds=["site"], + ) + + def __init__(self) -> None: + super().__init__() + self.slots: dict[str, str] = {} # slot id -> barcode + self.slot_ids = ["S1", "S2", "S3"] + + @hotel.reader + def _read_hotel(self) -> ResourceSnapshot: + return ResourceSnapshot( + index=[ + ResourceIndexEntry( + uri="labwire:hotel", + kinds=["deck"], + children={"kinds": ["site"], "ids": list(self.slot_ids)}, # pyright: ignore[reportArgumentType] + ) + ], + content=HotelState( + occupied=[ + SlotContents( + uri=f"labwire:hotel/{slot}", plate_barcode=code, stored_minutes=1.0 + ) + for slot, code in sorted(self.slots.items()) + ], + capacity=len(self.slot_ids), + ), + ) + + @command(safety_class="S2") + async def store(self, ctx: CommandContext, slot: Slot, barcode: str) -> None: # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType] + """Store a plate in a slot.""" + self.slots[str(cast("str", slot)).rsplit("/", 1)[1]] = barcode + self.hotel.touch() + + @command(safety_class="S3") + async def purge(self, ctx: CommandContext, slot: Slot) -> None: # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType] + """Discard whatever a slot holds. Destroys a sample.""" + self.slots.pop(str(cast("str", slot)).rsplit("/", 1)[1], None) + self.hotel.touch() + + +@pytest.fixture +async def hotel() -> AsyncIterator[tuple[PlateHotel, InstrumentServer, LabwireClient]]: + instrument = PlateHotel() + server = InstrumentServer( + instrument, + confirmation_token=GRANT, + grant_store=Path(tempfile.mkdtemp(prefix="labwire-grants-")), + ) + client_end, server_end = MemoryTransport.pair() + server.attach(server_end) + async with LabwireClient.attach(client_end) as client: + yield instrument, server, client + await server.aclose() + + +# --- declaration ------------------------------------------------------------ + + +async def test_the_descriptor_declares_the_resource( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + descriptor = await client.describe() + assert len(descriptor.resources) == 1 + declared = descriptor.resources[0] + assert declared.uri == "labwire:hotel" + assert declared.item_kinds == ["site"] + assert declared.revision # a live snapshot, not a placeholder + + +async def test_the_capability_flag_advertises_resources( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + assert client.capabilities is not None + assert client.capabilities.resources is True + assert client.capabilities.grants is True + + +def test_numeric_content_without_a_unit_keyword_is_refused() -> None: + """SPEC 7.6: a units-optional state format would reopen F5.""" + + class Bare(BaseModel): + model_config = ConfigDict(extra="forbid") + volume: float # no unit keyword + + with pytest.raises(TypeError, match="unit"): + resource( + "labwire:x", + kind="consumable", + title="X", + description="X.", + content_model=Bare, + ) + + +def test_an_unregistered_kind_is_refused() -> None: + class Fine(BaseModel): + model_config = ConfigDict(extra="forbid") + label: str + + with pytest.raises(TypeError, match="registry"): + resource("labwire:x", kind="wells", title="X", description="X.", content_model=Fine) + # vendor-prefixed is fine + resource("labwire:x", kind="acme.slot", title="X", description="X.", content_model=Fine) + + +def test_a_multi_segment_uri_is_not_declarable() -> None: + class Fine(BaseModel): + model_config = ConfigDict(extra="forbid") + label: str + + with pytest.raises(TypeError, match="one path segment"): + resource("labwire:deck/plate", kind="deck", title="X", description="X.", content_model=Fine) + + +# --- read ------------------------------------------------------------------- + + +async def test_reading_returns_index_content_and_revision( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, _server, client = hotel + instrument.slots["S2"] = "BC-0042" + snapshot = await client.read_resource("labwire:hotel") + assert snapshot.uri == "labwire:hotel" + assert snapshot.index_complete is True + entry = snapshot.index[0] + assert entry.children is not None + assert entry.children.ids == ["S1", "S2", "S3"] + assert snapshot.content["occupied"][0]["plate_barcode"] == "BC-0042" + assert snapshot.content["occupied"][0]["uri"] == "labwire:hotel/S2" + + +async def test_an_unknown_resource_uri_is_refused_helpfully( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + with pytest.raises(UnknownReferenceError) as caught: + await client.read_resource("labwire:freezer") + assert caught.value.details is not None + assert caught.value.details["reason"] == "unknown_resource" + assert "labwire:hotel" in str(caught.value) # names what exists + + +async def test_the_revision_changes_when_content_changes_and_only_then( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, _server, client = hotel + first = (await client.read_resource("labwire:hotel")).revision + second = (await client.read_resource("labwire:hotel")).revision + assert first == second # derived, so a no-op read cannot move it + instrument.slots["S1"] = "BC-1" + third = (await client.read_resource("labwire:hotel")).revision + assert third != first + + +async def test_touch_emits_the_reserved_event( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, _server, client = hotel + events: list[tuple[str, dict[str, object]]] = [] + async with client.events() as stream: + instrument.slots["S3"] = "BC-9" + instrument.hotel.touch() + async with asyncio.timeout(5.0): + async for event in stream: + if event.name == "resource/changed": + events.append((event.name, event.data)) + break + assert events[0][1]["uri"] == "labwire:hotel" + assert isinstance(events[0][1]["revision"], str) + + +async def test_a_touch_that_changed_nothing_emits_nothing( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, _server, client = hotel + await client.read_resource("labwire:hotel") # settle the last revision + seen: list[str] = [] + async with client.events() as stream: + instrument.hotel.touch() # nothing changed + instrument.slots["S1"] = "BC-2" + instrument.hotel.touch() # this one changed + async with asyncio.timeout(5.0): + async for event in stream: + if event.name == "resource/changed": + seen.append(str(event.data["revision"])) + break + assert len(seen) == 1 + + +# --- typed references ------------------------------------------------------- + + +async def test_a_valid_reference_resolves_and_the_command_runs( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, _server, client = hotel + handle = await client.submit( + "store", {"slot": "labwire:hotel/S1", "barcode": "BC-7"}, confirmation=GRANT + ) + await handle.result(timeout=5.0) + assert instrument.slots["S1"] == "BC-7" + + +async def test_an_unknown_item_is_refused_with_the_full_error_shape( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + with pytest.raises(UnknownReferenceError) as caught: + await client.submit( + "store", {"slot": "labwire:hotel/S9", "barcode": "BC-1"}, confirmation=GRANT + ) + details = caught.value.details + assert details is not None + assert details["pointer"] == "/slot" + assert details["reference"] == "labwire:hotel/S9" + assert details["expected_kind"] == "site" + assert details["reason"] == "no_such_item" + assert details["resolved_prefix"] == "labwire:hotel" + assert details["read"] == {"method": "resource/read", "params": {"uri": "labwire:hotel"}} + assert "labwire:hotel/S1" in details["did_you_mean"] + + +async def test_a_kind_mismatch_is_named_as_such( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + """Passing the resource itself where a site is wanted is a kind error.""" + _instrument, _server, client = hotel + with pytest.raises(UnknownReferenceError) as caught: + await client.submit( + "store", {"slot": "labwire:hotel", "barcode": "BC-1"}, confirmation=GRANT + ) + assert caught.value.details is not None + assert caught.value.details["reason"] == "kind_mismatch" + assert caught.value.details["resolved_kinds"] == ["deck"] + + +async def test_a_malformed_reference_is_refused_before_anything_else( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + with pytest.raises(UnknownReferenceError) as caught: + await client.submit( + "store", {"slot": "just-a-slot-name", "barcode": "BC-1"}, confirmation=GRANT + ) + assert caught.value.details is not None + assert caught.value.details["reason"] == "malformed_uri" + + +async def test_reference_failure_precedes_confirmation( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + """SPEC 12.1: an agent is never asked to confirm a call that cannot run.""" + _instrument, _server, client = hotel + with pytest.raises(UnknownReferenceError): + await client.submit("store", {"slot": "labwire:hotel/S9", "barcode": "B"}) + + +async def test_reference_failure_never_spends_a_grant_or_creates_a_pending( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, server, client = hotel + with pytest.raises(UnknownReferenceError): + await client.submit("purge", {"slot": "labwire:hotel/S9"}) + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + assert store.pending(now=server.clock.now()) == [] + + +# --- if_revision ------------------------------------------------------------ + + +async def test_a_fresh_revision_passes_and_a_stale_one_is_refused( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + _instrument, _server, client = hotel + snapshot = await client.read_resource("labwire:hotel") + + handle = await client.submit( + "store", + {"slot": "labwire:hotel/S1", "barcode": "BC-1"}, + confirmation=GRANT, + if_revision={"labwire:hotel": snapshot.revision}, + ) + await handle.result(timeout=5.0) + + # the same (now stale) revision is refused, with the recovery read + with pytest.raises(StaleRevisionError) as caught: + await client.submit( + "store", + {"slot": "labwire:hotel/S2", "barcode": "BC-2"}, + confirmation=GRANT, + if_revision={"labwire:hotel": snapshot.revision}, + ) + details = caught.value.details + assert details is not None + assert details["submitted_revision"] == snapshot.revision + assert details["current_revision"] != snapshot.revision + assert details["read"] == {"method": "resource/read", "params": {"uri": "labwire:hotel"}} + + +async def test_the_terminal_status_reports_the_new_revision( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + """The write-returns-the-new-revision pattern: no re-read between steps.""" + _instrument, _server, client = hotel + before = (await client.read_resource("labwire:hotel")).revision + handle = await client.submit( + "store", {"slot": "labwire:hotel/S1", "barcode": "BC-1"}, confirmation=GRANT + ) + await handle.result(timeout=5.0) + status = await handle.status() + assert status.resource_revisions is not None + assert status.resource_revisions[0].uri == "labwire:hotel" + assert status.resource_revisions[0].revision != before + + # and the reported revision passes an if_revision check immediately + follow_up = await client.submit( + "store", + {"slot": "labwire:hotel/S2", "barcode": "BC-2"}, + confirmation=GRANT, + if_revision={"labwire:hotel": status.resource_revisions[0].revision}, + ) + await follow_up.result(timeout=5.0) + + +async def test_stale_revision_precedes_authorization( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + """A stale plan never costs an operator approval (SPEC 10.5).""" + instrument, server, client = hotel + old = (await client.read_resource("labwire:hotel")).revision + instrument.slots["S1"] = "BC-1" # the deck moves + with pytest.raises(StaleRevisionError): + await client.submit( + "purge", + {"slot": "labwire:hotel/S1"}, + if_revision={"labwire:hotel": old}, + ) + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + assert store.pending(now=server.clock.now()) == [] # nothing was recorded + + +# --- closure ---------------------------------------------------------------- + + +def test_an_instrument_whose_references_dangle_cannot_be_described() -> None: + class Dangling(Instrument): + identity = IdentityInfo( + manufacturer="m", model="d", serial_number="s", firmware_version="1" + ) + + @command(safety_class="S1") + async def fetch(self, ctx: CommandContext, slot: Slot) -> None: # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType] + """Fetch from a slot, but no resource enumerates slots here.""" + + with pytest.raises(Exception, match="labwire:hotel"): + Dangling().describe() + + +def test_s3_with_a_grant_store_is_required_to_start() -> None: + """SPEC 6.1: hazardous commands with no way to authorize them refuse to start.""" + instrument = PlateHotel() + with pytest.raises(TypeError, match="grant store"): + InstrumentServer(instrument) # no grant_store, no LABWIRE_GRANT_STORE + + +async def test_purge_needs_a_grant_end_to_end( + hotel: tuple[PlateHotel, InstrumentServer, LabwireClient], +) -> None: + instrument, server, client = hotel + instrument.slots["S1"] = "BC-1" + with pytest.raises(AuthorizationRequiredError) as refused: + await client.submit("purge", {"slot": "labwire:hotel/S1"}, confirmation=GRANT) + assert refused.value.details is not None + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + from datetime import timedelta + + grant = store.approve( + refused.value.details["request_id"], + now=server.clock.now(), + ttl=timedelta(minutes=15), + max_uses=1, + ) + handle = await client.submit( + "purge", {"slot": "labwire:hotel/S1"}, authorization=grant.grant_id + ) + await handle.result(timeout=5.0) + assert "S1" not in instrument.slots diff --git a/packages/core/tests/test_server_protocol.py b/packages/core/tests/test_server_protocol.py index a856ed0..960549a 100644 --- a/packages/core/tests/test_server_protocol.py +++ b/packages/core/tests/test_server_protocol.py @@ -159,7 +159,7 @@ async def test_initialize_result_and_gating() -> None: "capabilities": {}, }, ) - assert result["protocol_version"] == "0.2" + assert result["protocol_version"] == "0.3" # resources and grants advertise False until the server implements # them (SPEC §6.1): a not-yet-implementing server must say so. assert result["capabilities"] == { diff --git a/packages/core/tests/test_smoke.py b/packages/core/tests/test_smoke.py index 23e5021..ac9ae8c 100644 --- a/packages/core/tests/test_smoke.py +++ b/packages/core/tests/test_smoke.py @@ -2,8 +2,8 @@ def test_version_is_set() -> None: - assert __version__ == "0.2.1" + assert __version__ == "0.3.0.dev0" def test_protocol_version() -> None: - assert PROTOCOL_VERSION == "0.2" + assert PROTOCOL_VERSION == "0.3" diff --git a/packages/core/tests/test_units_and_safety.py b/packages/core/tests/test_units_and_safety.py index 5c04752..1673238 100644 --- a/packages/core/tests/test_units_and_safety.py +++ b/packages/core/tests/test_units_and_safety.py @@ -1,6 +1,8 @@ """Tests for v0.2: mandatory UCUM units and S0-S3 safety classes (SPEC §7, §8.6).""" +import tempfile from collections.abc import AsyncIterator +from pathlib import Path from typing import TypedDict import pytest @@ -107,6 +109,9 @@ async def estop(self, ctx: CommandContext) -> dict[str, bool]: async def _connect(**server_kwargs: object) -> tuple[SafetyRig, InstrumentServer, LabwireClient]: rig = SafetyRig() + # The rig declares an S3 command, and a server with S3 commands and no + # grant store must refuse to start (SPEC 6.1), so every connection gets one. + server_kwargs.setdefault("grant_store", Path(tempfile.mkdtemp(prefix="labwire-grants-"))) server = InstrumentServer(rig, **server_kwargs) # pyright: ignore[reportArgumentType] client_end, server_end = MemoryTransport.pair() server.attach(server_end) @@ -284,13 +289,90 @@ async def test_s2_without_confirmation_is_rejected( assert error.details == {"safety_class": "S2"} -async def test_s3_without_confirmation_is_rejected( +async def test_s3_without_a_grant_is_rejected_with_a_pending_request( rig: tuple[SafetyRig, InstrumentServer, LabwireClient], ) -> None: + """S3 takes a grant, not a confirmation, and the refusal is productive.""" + from labwire.core import AuthorizationRequiredError + _instrument, _server, client = rig - with pytest.raises(ConfirmationRequiredError) as excinfo: + with pytest.raises(AuthorizationRequiredError) as excinfo: + await client.submit("irradiate", {"joules": 5.0}) + details = excinfo.value.details + assert details is not None + assert details["safety_class"] == "S3" + assert details["reason"] == "absent" + assert details["mintable_by_agent"] is False + assert details["request_id"].startswith("req-") + assert details["params_digest"].startswith("sha256:") + + +async def test_a_confirmation_never_satisfies_s3( + rig: tuple[SafetyRig, InstrumentServer, LabwireClient], +) -> None: + """The F4 fix: the standing S2 token does not move the laser.""" + from labwire.core import AuthorizationRequiredError + + _instrument, _server, client = rig + with pytest.raises(AuthorizationRequiredError): + await client.submit("irradiate", {"joules": 5.0}, confirmation=GRANT) + + +async def test_an_approved_grant_runs_s3_exactly_once( + rig: tuple[SafetyRig, InstrumentServer, LabwireClient], +) -> None: + """Refusal creates the request; approval creates the grant; binding holds.""" + from labwire.core import AuthorizationRequiredError + + _instrument, server, client = rig + with pytest.raises(AuthorizationRequiredError) as refused: + await client.submit("irradiate", {"joules": 5.0}) + assert refused.value.details is not None + request_id = refused.value.details["request_id"] + + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + from datetime import timedelta + + grant = store.approve(request_id, now=server.clock.now(), ttl=timedelta(minutes=15), max_uses=1) + handle = await client.submit("irradiate", {"joules": 5.0}, authorization=grant.grant_id) + assert (await handle.result(timeout=5.0)) == {"delivered_j": 5.0} + + # the single use is spent: the identical call is refused as exhausted + with pytest.raises(AuthorizationRequiredError) as spent: + await client.submit("irradiate", {"joules": 5.0}, authorization=grant.grant_id) + assert spent.value.details is not None + assert spent.value.details["reason"] == "exhausted" + + +async def test_a_grant_binds_to_the_exact_parameters( + rig: tuple[SafetyRig, InstrumentServer, LabwireClient], +) -> None: + """A valid, unexpired, correct-command grant still fails on other values. + + This is the beat that proves the binding is to parameters rather than an + S3-shaped password. + """ + from datetime import timedelta + + from labwire.core import AuthorizationRequiredError + + _instrument, server, client = rig + with pytest.raises(AuthorizationRequiredError) as refused: await client.submit("irradiate", {"joules": 5.0}) - assert excinfo.value.details == {"safety_class": "S3"} + assert refused.value.details is not None + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + grant = store.approve( + refused.value.details["request_id"], + now=server.clock.now(), + ttl=timedelta(minutes=15), + max_uses=1, + ) + with pytest.raises(AuthorizationRequiredError) as mismatched: + await client.submit("irradiate", {"joules": 9.0}, authorization=grant.grant_id) + assert mismatched.value.details is not None + assert mismatched.value.details["reason"] == "params_mismatch" async def test_s2_with_the_configured_token_runs( From 157d046da00e54775874d12cc59c203e7ed9df75 Mon Sep 17 00:00:00 2001 From: Silous Ramelli <204268110+TheRoboMaster123@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:25:17 -0700 Subject: [PATCH 3/5] refactor(bridges)!: the deck becomes a resource; addresses become references The V3 migration, all of it intended. The PyLabRobot bridge's invented address grammar is gone. addressing.py is now a bijection between PyLabRobot's tree and the protocol's URI space: labwire:deck//, composed by the one protocol- defined rule from the read result's index. Its discipline survived the rewrite: one spelling per thing, derived internal names refused with the canonical URI, and every failure naming what would have worked. describe_deck is deleted, not deprecated. The deck is the labwire:deck resource, declared at class scope with DeckState as its content model, read with resource/read, its revision derived so it cannot go stale silently, touched after every operation so resource/changed fires only when something moved. The command surface is nine operations; the deck is not one of them. Parameters that name things are typed references now. Container and TipSite are ResourceRef aliases, so the resource_ref keyword and its generated read-this-resource sentence ride inside params_schema with no pattern anywhere, and the server validates each value against a fresh read before a handler runs. The tests that asserted the old pattern now assert its absence. The annotation file rekeys resources by URI and loses its per-resource safety_class, which was documented in three places as reported-but-not- enforced; keeping a field that still cannot raise a call's class would be keeping a lie. locked stays and is enforced; hazard stays and now surfaces in the deck resource content where an agent actually reads it. The syringe pump gains labwire:syringe, a consumable resource on an instrument with no references at all, deliberately: the primitive is not deck-shaped, and this exercises content typing, derived revisions, and change notification without a single resource_ref. The balance and PSU declare nothing and lose nothing. The ophyd bridge declares resources: [] and its README says why in one paragraph: a signal-shaped instrument has no tree with nowhere to live, and inventing a resource would be surface for its own sake. Both dilution demos run on v0.3 end to end: URIs on the wire, the deck read as a resource with its revision printed, signed bundles verifying. Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 +- examples/liquid_handling/claude_dilution.py | 53 ++++-- examples/liquid_handling/dilution.py | 19 +- .../liquid_handling/labwire-pylabrobot.yaml | 10 +- examples/liquid_handling/rig.py | 6 +- packages/bridges/ophyd/README.md | 15 ++ .../labwire/bridges/pylabrobot/__init__.py | 18 +- .../labwire/bridges/pylabrobot/addressing.py | 172 ++++++++---------- .../labwire/bridges/pylabrobot/annotations.py | 39 ++-- .../src/labwire/bridges/pylabrobot/bridge.py | 90 +++++---- .../src/labwire/bridges/pylabrobot/cli.py | 2 +- .../src/labwire/bridges/pylabrobot/deck.py | 109 ++++++++--- .../labwire/bridges/pylabrobot/introspect.py | 84 +++++---- .../pylabrobot/tests/test_addressing.py | 123 ++++++------- .../tests/test_deck_introspection.py | 48 +++-- .../pylabrobot/tests/test_deck_state.py | 63 ++++--- .../tests/test_liquid_handler_bridge.py | 171 +++++++++++------ .../bridges/pylabrobot/tests/test_plr_cli.py | 9 +- packages/core/src/labwire/core/__init__.py | 2 + .../core/src/labwire/core/capabilities.py | 31 +++- packages/core/src/labwire/core/server.py | 29 ++- .../src/labwire/drivers/syringe_pump.py | 52 +++++- 22 files changed, 725 insertions(+), 426 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6e918f..3d6e810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,8 +51,8 @@ jobs: # addressing and introspection are duck-typed and import no PyLabRobot. run: | uv run --no-sync python -c " - from labwire.bridges.pylabrobot import Address, command_surface - assert Address.parse('plate/A1').item == 'A1' - assert len(command_surface()) == 10 + from labwire.bridges.pylabrobot import command_surface, split_deck_uri + assert split_deck_uri('labwire:deck/plate/A1') == ('plate', 'A1') + assert len(command_surface()) == 9 print('pure layers import without pylabrobot') " diff --git a/examples/liquid_handling/claude_dilution.py b/examples/liquid_handling/claude_dilution.py index 80081dc..4cc3524 100644 --- a/examples/liquid_handling/claude_dilution.py +++ b/examples/liquid_handling/claude_dilution.py @@ -39,17 +39,14 @@ SYSTEM_PROMPT = f"""You are operating a liquid handler over the Labwire protocol. The \ machine is driven by PyLabRobot, exposed to you as tools. -Start by calling describe_deck. You cannot plan anything until you know what labware is \ -loaded and what it holds; nothing else tells you. - -Addressing: wells and tip spots are named "/", for example \ -"source_plate/A1" or "tips/A1". Use exactly the labware names describe_deck reports. \ -Never guess a name, and never use PyLabRobot's internal names. +Read the labwire:deck resource before planning: its index lists every container and \ +tip site a command parameter can reference, as full URIs. Use those URIs exactly as \ +the index spells them. Goal: run a two-fold serial dilution across row A of the dilution plate, \ {{steps}} steps. Each step moves {STEP_VOLUME_UL:.0f} uL from the previous well into the \ -next, so step 1 goes from the dye in source_plate/A1 into dilution_plate/A1, step 2 \ -from dilution_plate/A1 into dilution_plate/A2, and so on. +next, so step 1 goes from the dye stock well A1 of the source plate into the first \ +dilution well, step 2 from that well into the second, and so on. Technique: use a fresh tip for each step. Pick up one tip, transfer, then discard it \ before the next step. Carrying one tip through the series would contaminate it. @@ -73,6 +70,27 @@ async def build_tools( tools: list[dict[str, Any]] = [] registry: dict[str, CommandSpec] = {} descriptor = await rig.client.describe() + if descriptor.resources: + # The read is model-callable, not host-optional: the uri parameter is + # an enum of the declared resources, so the model cannot get it wrong. + tools.append( + { + "name": "read_resource", + "description": ( + "Read one of this instrument's resources: its typed content and " + "the index of everything a command parameter can reference. " + + " ".join(f"{r.uri}: {r.description}" for r in descriptor.resources) + ), + "input_schema": { + "type": "object", + "additionalProperties": False, + "required": ["uri"], + "properties": { + "uri": {"type": "string", "enum": [r.uri for r in descriptor.resources]} + }, + }, + } + ) for spec in descriptor.commands: if spec.name == "stop": continue # the agent has no reason to halt the machine mid-series @@ -112,6 +130,9 @@ async def execute_tool( Transfer run ids are recorded as they happen, so the demo verifies the exact bundle the agent produced rather than the newest file on disk. """ + if name == "read_resource": + snapshot = await client.read_resource(str(arguments["uri"])) + return snapshot.model_dump_json(exclude_none=True) if name not in registry: return f"ERROR: no such tool {name!r}" spec = registry[name] @@ -133,11 +154,13 @@ async def execute_tool( async def prepare(rig: DilutionRig, steps: int) -> None: """Declare what the plates hold, as a human loading the deck would.""" - await rig.call("set_well_volume", {"well": "source_plate/A1", "volume_ul": DYE_VOLUME_UL}) + await rig.call( + "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": DYE_VOLUME_UL} + ) for well in dilution_wells(steps): await rig.call("set_well_volume", {"well": well, "volume_ul": DILUENT_VOLUME_UL}) print( - f"operator loaded the deck: {DYE_VOLUME_UL:.0f} uL dye in source_plate/A1, " + f"operator loaded the deck: {DYE_VOLUME_UL:.0f} uL dye in the source plate, " f"{DILUENT_VOLUME_UL:.0f} uL diluent in {steps} dilution wells\n" ) @@ -182,9 +205,9 @@ async def run_claude(rig: DilutionRig, api_key: str, steps: int, transfers: list async def run_scripted(rig: DilutionRig, steps: int, transfers: list[str]) -> None: """The same series, planned by this script rather than by an agent.""" - source = "source_plate/A1" + source = "labwire:deck/source_plate/A1" for index, target in enumerate(dilution_wells(steps)): - await rig.call("pick_up_tips", {"tip_spots": [f"tips/A{index + 1}"]}) + await rig.call("pick_up_tips", {"tip_spots": [f"labwire:deck/tips/A{index + 1}"]}) _result, run_id = await rig.call( "transfer", {"source": source, "targets": [target], "volumes_ul": [STEP_VOLUME_UL]} ) @@ -209,10 +232,10 @@ async def main() -> None: print("ANTHROPIC_API_KEY not set - falling back to the scripted dilution\n") await run_scripted(rig, steps, transfers) - state, _run = await rig.call("describe_deck") + snapshot = await rig.client.read_resource("labwire:deck") print("\nfinal deck contents:") - for well in state["contents"]: - print(f" {well['address']:20} {well['volume_ul']:7.1f} uL") + for well in snapshot.content["contents"]: + print(f" {well['uri']:36} {well['volume_ul']:7.1f} uL") if not transfers: print("\nno liquid was moved, so there is no signed evidence") diff --git a/examples/liquid_handling/dilution.py b/examples/liquid_handling/dilution.py index bc8fbcb..afe1e96 100644 --- a/examples/liquid_handling/dilution.py +++ b/examples/liquid_handling/dilution.py @@ -49,16 +49,17 @@ async def show_capabilities(rig: DilutionRig) -> None: async def show_deck(rig: DilutionRig, heading: str) -> dict[str, Any]: - """Read the deck over the protocol and print what is on it.""" - state, _run = await rig.call("describe_deck") - print(f"\n{heading}") + """Read the labwire:deck resource and print what is on it.""" + snapshot = await rig.client.read_resource("labwire:deck") + state: dict[str, Any] = snapshot.content + print(f"\n{heading} (revision {snapshot.revision})") for item in state["labware"]: if item["kind"] not in {"plate", "tip_rack"}: continue extra = f"{item['tips_available']} tips left" if item["kind"] == "tip_rack" else "" grid = item.get("grid") or {} shape = f"{grid.get('rows')}x{grid.get('columns')}" if grid else "" - print(f" {item['address']:16} {item['kind']:9} {shape:6} {extra}") + print(f" {item['uri']:36} {item['kind']:9} {shape:6} {extra}") return state @@ -82,7 +83,9 @@ async def main() -> None: # PyLabRobot cannot see into a plate a human placed on the deck, so the # run starts by telling it what is there. This moves nothing (S1). wells = dilution_wells(steps) - await rig.call("set_well_volume", {"well": "source_plate/A1", "volume_ul": DYE_VOLUME_UL}) + await rig.call( + "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": DYE_VOLUME_UL} + ) for well in wells: await rig.call("set_well_volume", {"well": well, "volume_ul": DILUENT_VOLUME_UL}) print( @@ -91,10 +94,10 @@ async def main() -> None: ) print("\nserial dilution, fresh tip per step:") - source = "source_plate/A1" + source = "labwire:deck/source_plate/A1" transfer_runs: list[str] = [] for index, target in enumerate(wells): - tip = f"tips/A{index + 1}" + tip = f"labwire:deck/tips/A{index + 1}" await rig.call("pick_up_tips", {"tip_spots": [tip]}) _result, run_id = await rig.call( "transfer", @@ -112,7 +115,7 @@ async def main() -> None: state = await show_deck(rig, "deck after the run:") print("\n contents:") for well in state["contents"]: - print(f" {well['address']:20} {well['volume_ul']:7.1f} uL") + print(f" {well['uri']:36} {well['volume_ul']:7.1f} uL") mounted = sum(1 for channel in state["channels"] if channel["has_tip"]) print(f"\n channels holding a tip: {mounted} (every tip was discarded)") diff --git a/examples/liquid_handling/labwire-pylabrobot.yaml b/examples/liquid_handling/labwire-pylabrobot.yaml index 76815ca..5102729 100644 --- a/examples/liquid_handling/labwire-pylabrobot.yaml +++ b/examples/liquid_handling/labwire-pylabrobot.yaml @@ -26,18 +26,18 @@ labware: description: Corning Costar 96-well flat-bottom plate, 360 uL per well. resources: - source_plate: + labwire:deck/source_plate: description: Holds the concentrated dye stock in A1. hazard: none - dilution_plate: + labwire:deck/dilution_plate: description: Row A receives the two-fold dilution series. hazard: none # What a hazard annotation looks like in practice. Nothing on this deck is # dangerous, so the example is commented out rather than faked: # - # acid_stock: + # labwire:deck/acid_stock: # description: 1 M hydrochloric acid. # hazard: corrosive - # safety_class: S3 # reported and recorded; see SPEC-FINDINGS.md - # locked: true # refused outright, which v0.2 *can* enforce + # locked: true # refused outright; hazard itself surfaces in the + # # deck resource content for the agent to read diff --git a/examples/liquid_handling/rig.py b/examples/liquid_handling/rig.py index a1551e7..4cd132e 100644 --- a/examples/liquid_handling/rig.py +++ b/examples/liquid_handling/rig.py @@ -114,10 +114,10 @@ def dilution_wells(steps: int) -> list[str]: """Addresses of the dilution series, across row A of the dilution plate. Example: - >>> dilution_wells(3) - ['dilution_plate/A1', 'dilution_plate/A2', 'dilution_plate/A3'] + >>> dilution_wells(2) + ['labwire:deck/dilution_plate/A1', 'labwire:deck/dilution_plate/A2'] """ - return [f"dilution_plate/A{index + 1}" for index in range(steps)] + return [f"labwire:deck/dilution_plate/A{index + 1}" for index in range(steps)] def demo_steps() -> int: diff --git a/packages/bridges/ophyd/README.md b/packages/bridges/ophyd/README.md index ba7fe19..87556fa 100644 --- a/packages/bridges/ophyd/README.md +++ b/packages/bridges/ophyd/README.md @@ -125,6 +125,21 @@ Changing a class takes an explicit annotation, never a heuristic. [DESIGN.md](DESIGN.md) has the full reasoning, including why positioners are moved rather than poked. +## Why this bridge declares no resources + +Protocol v0.3 added resources for instrument state that is a tree with +nowhere else to live. An ophyd device has none: its structure is fixed by +its class and is already fully expressed as the descriptor plus scalar +channels, so inventing a resource here would be adding surface to prove a +point. The bridge declares `resources: []`, which costs a signal-shaped +instrument exactly nothing, and that asymmetry is useful evidence about the +primitive itself. + +One v0.3 change does reach this bridge: a `CommandAnnotation.safety_class` +of `S3` now genuinely bites. An S3 command requires an operator grant from +a server-side store (`labwire grant`), and a server with S3 commands and no +store refuses to start; see SPEC 8.6 before raising a command's class. + ## LIMITATIONS Read this before believing anything above. diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/__init__.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/__init__.py index 57f3db9..faf7b9f 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/__init__.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/__init__.py @@ -6,17 +6,17 @@ PyLabRobot is installed. You need it to have a liquid handler to pass in. Example: - >>> from labwire.bridges.pylabrobot import Address - >>> Address.parse("source_plate/A1").item - 'A1' + >>> from labwire.bridges.pylabrobot import split_deck_uri + >>> split_deck_uri("labwire:deck/source_plate/A1") + ('source_plate', 'A1') """ from labwire.bridges.pylabrobot.addressing import ( - ADDRESS_PATTERN, - Address, - address_of, + DECK_URI, resolve, resolve_all, + split_deck_uri, + uri_of, ) from labwire.bridges.pylabrobot.annotations import ( AnnotationError, @@ -51,8 +51,7 @@ ) __all__ = [ - "ADDRESS_PATTERN", - "Address", + "DECK_URI", "AnnotationError", "AnnotationFile", "ChannelState", @@ -70,7 +69,6 @@ "Unresolved", "UnresolvedReason", "WellContents", - "address_of", "addressable_resources", "annotation_for", "check", @@ -82,4 +80,6 @@ "map_error", "resolve", "resolve_all", + "split_deck_uri", + "uri_of", ] diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/addressing.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/addressing.py index 3e10102..a29fb9b 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/addressing.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/addressing.py @@ -1,90 +1,76 @@ -"""Naming labware and its items across the wire. +"""The bijection between PyLabRobot's resource tree and the URI space. -PyLabRobot operations take live objects: a ``Well``, a ``TipSpot``, a -``Plate``. JSON-RPC carries JSON. This module is the whole of the translation -between the two, and it is deliberately the smallest, strictest thing that can -do the job. +Until v0.3 this module owned an invented address grammar (``"plate/A1"``), +published as a JSON Schema pattern and checked at runtime. That grammar is +gone, replaced by the protocol's own scheme (SPEC §10.1): the deck resource +is ``labwire:deck``, labware standing on it is ``labwire:deck/``, and +an item of that labware is ``labwire:deck//``, composed by the one +protocol-defined rule from the read result's index. There is nothing +bridge-private left to learn. -An address is ``""`` or ``"/"``:: - - source_plate the plate itself - source_plate/A1 one well of it - tips/H12 one tip spot - -PyLabRobot's own derived names (``source_plate_well_A1``) are not accepted, -and neither is its range syntax (``plate["A1:H1"]``). Both are explained in -`DESIGN.md <../../../DESIGN.md>`_: derived names leak a naming rule an agent -should not have to know, and ranges duplicate a cardinality mechanism JSON -arrays already provide. +What stayed is this module's discipline: one spelling per thing (PyLabRobot's +derived internal names are refused with the canonical URI), and every failure +names what would have worked. Example: - >>> Address.parse("source_plate/A1") - Address(labware='source_plate', item='A1') + >>> split_deck_uri("labwire:deck/source_plate/A1") + ('source_plate', 'A1') """ -import re -from dataclasses import dataclass from typing import Any from labwire.core.errors import ValidationError -_LABWARE = r"[A-Za-z0-9][A-Za-z0-9_.\-]*" -_ITEM = r"[A-Za-z0-9_]+" -ADDRESS_PATTERN = rf"^{_LABWARE}(/{_ITEM})?$" -"""JSON Schema ``pattern`` for an address parameter. +DECK_URI = "labwire:deck" +"""The one resource the PyLabRobot bridge declares.""" -Shape only. Whether the address names something that exists on this deck is -not expressible in JSON Schema, and is checked at resolution time instead. -That gap is the subject of a SPEC-FINDINGS entry. -""" -_ADDRESS_RE = re.compile(ADDRESS_PATTERN) +def split_deck_uri(uri: object) -> tuple[str, str | None]: + """Split a deck item URI into (labware name, item id or None). - -@dataclass(frozen=True) -class Address: - """A labware name, optionally with one item identifier. + Raises :class:`ValidationError` for anything that is not a well-formed + URI under ``labwire:deck``. Example: - >>> str(Address("tips", "A1")) - 'tips/A1' + >>> split_deck_uri("labwire:deck/tips") + ('tips', None) """ - - labware: str - item: str | None = None - - def __str__(self) -> str: - return self.labware if self.item is None else f"{self.labware}/{self.item}" - - @classmethod - def parse(cls, text: object) -> "Address": - """Parse an address, raising :class:`ValidationError` if malformed. - - Typed ``object`` rather than ``str`` on purpose: addresses arrive as - arbitrary JSON, so the type check is a runtime obligation, not an - assumption a caller can be trusted to have met. - - Example: - >>> Address.parse("plate").item is None - True - """ - if not isinstance(text, str) or not _ADDRESS_RE.match(text): - raise ValidationError( - f"malformed address {text!r}: expected 'labware' or 'labware/item', " - "for example 'source_plate/A1'" - ) - labware, _, item = text.partition("/") - return cls(labware, item or None) + if not isinstance(uri, str) or not uri.startswith(DECK_URI + "/"): + raise ValidationError( + f"malformed reference {uri!r}: expected '{DECK_URI}/' or " + f"'{DECK_URI}//', for example '{DECK_URI}/source_plate/A1'" + ) + rest = uri.removeprefix(DECK_URI + "/") + if not rest or rest.endswith("/") or "//" in rest: + raise ValidationError( + f"malformed reference {uri!r}: empty path segment; expected " + f"'{DECK_URI}/' or '{DECK_URI}//', for example " + f"'{DECK_URI}/source_plate/A1'" + ) + parts = rest.split("/") + if not all(parts): + raise ValidationError( + f"malformed reference {uri!r}: empty path segment; for example " + f"'{DECK_URI}/source_plate/A1'" + ) + if len(parts) > 2: + raise ValidationError( + f"malformed reference {uri!r}: at most '{DECK_URI}//', " + f"for example '{DECK_URI}/source_plate/A1'" + ) + labware = parts[0] + item = parts[1] if len(parts) == 2 else None + return labware, item -def address_of(resource: Any) -> str: - """The canonical address of a PyLabRobot resource. +def uri_of(resource: Any) -> str: + """The canonical URI of a PyLabRobot resource on the deck. - Items of an itemized resource (wells, tip spots) address as - ``parent/identifier``; everything else addresses by its own name. + Items of an itemized resource (wells, tip spots) compose through their + parent per the protocol rule; everything else is ``labwire:deck/``. Example: - >>> # address_of(plate.get_item("A1")) -> 'source_plate/A1' + >>> # uri_of(plate.get_item("A1")) -> 'labwire:deck/source_plate/A1' """ parent = getattr(resource, "parent", None) if parent is not None and hasattr(parent, "get_child_identifier"): @@ -93,8 +79,8 @@ def address_of(resource: Any) -> str: except Exception: # not an item of that parent after all identifier = None if identifier is not None: - return f"{parent.name}/{identifier}" - return str(resource.name) + return f"{DECK_URI}/{parent.name}/{identifier}" + return f"{DECK_URI}/{resource.name}" def _known_labware(root: Any) -> list[str]: @@ -105,66 +91,68 @@ def _known_labware(root: Any) -> list[str]: parent = getattr(child, "parent", None) if parent is not None and hasattr(parent, "get_child_identifier"): continue - names.append(str(child.name)) + names.append(f"{DECK_URI}/{child.name}") return sorted(names) -def resolve(root: Any, address: str | Address) -> Any: - """Resolve an address against a deck, or explain precisely why it fails. +def resolve(root: Any, uri: str) -> Any: + """Resolve a deck URI to the live PyLabRobot object, or explain why not. - ``root`` is any PyLabRobot resource that contains the target, normally the - ``LiquidHandler`` or its deck. Every failure raises - :class:`ValidationError` naming the address and what would have worked, - because an agent that gets a bare "not found" has nothing to act on. + The protocol server validates references before a handler runs + (SPEC §10.4); this resolution is the handler's own step from a URI the + server already vouched for to the object PyLabRobot needs. It keeps the + full errors anyway, because a defensive layer that assumes the layer + above is correct is not a defensive layer. Example: - >>> # resolve(lh, "source_plate/A1").name -> 'source_plate_well_A1' + >>> # resolve(lh, "labwire:deck/source_plate/A1").name + >>> # 'source_plate_well_A1' """ - parsed = Address.parse(address) if isinstance(address, str) else address + labware_name, item = split_deck_uri(uri) try: - labware = root.get_resource(parsed.labware) + labware = root.get_resource(labware_name) except Exception as exc: known = _known_labware(root) raise ValidationError( - f"no labware named {parsed.labware!r} on the deck; " + f"no labware named {labware_name!r} on the deck; " f"known labware: {', '.join(known) if known else '(none assigned)'}" ) from exc # PyLabRobot's get_resource searches the entire subtree by name, so a # derived name like 'source_plate_well_A1' resolves happily. Accepting it # would give every well two spellings; refusing it with the canonical one - # costs an agent a single retry and keeps one way to say a thing. + # keeps one way to say a thing. item_parent = getattr(labware, "parent", None) if item_parent is not None and hasattr(item_parent, "get_child_identifier"): raise ValidationError( - f"{parsed.labware!r} is an item of {item_parent.name!r}, not labware in its own " - f"right; address it as {address_of(labware)!r}" + f"{labware_name!r} is an item of {item_parent.name!r}, not labware in its " + f"own right; address it as {uri_of(labware)!r}" ) - if parsed.item is None: + if item is None: return labware if not hasattr(labware, "get_item"): raise ValidationError( - f"{parsed.labware!r} is a {type(labware).__name__}, which has no addressable " - f"items, so {str(parsed)!r} cannot be resolved; address it as " - f"{parsed.labware!r} instead" + f"{labware_name!r} is a {type(labware).__name__}, which has no addressable " + f"items, so {uri!r} cannot be resolved; address it as " + f"'{DECK_URI}/{labware_name}' instead" ) try: - return labware.get_item(parsed.item) + return labware.get_item(item) except Exception as exc: rows = getattr(labware, "num_items_y", None) columns = getattr(labware, "num_items_x", None) shape = f" ({rows} rows by {columns} columns)" if rows and columns else "" raise ValidationError( - f"{parsed.labware!r} has no item {parsed.item!r}{shape}; items are addressed like 'A1'" + f"{labware_name!r} has no item {item!r}{shape}; items are addressed like 'A1'" ) from exc -def resolve_all(root: Any, addresses: list[str]) -> list[Any]: - """Resolve a list of addresses, failing on the first bad one. +def resolve_all(root: Any, uris: list[str]) -> list[Any]: + """Resolve a list of URIs, failing on the first bad one. Example: - >>> # resolve_all(lh, ["plate/A1", "plate/B1"]) + >>> # resolve_all(lh, ["labwire:deck/plate/A1", "labwire:deck/plate/B1"]) """ - return [resolve(root, address) for address in addresses] + return [resolve(root, uri) for uri in uris] diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/annotations.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/annotations.py index ea6045a..dd5d8dc 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/annotations.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/annotations.py @@ -21,19 +21,20 @@ labware: Cor_96_wellplate_360ul_Fb: {description: A 96-well Costar plate.} resources: - acid_stock: + labwire:deck/acid_stock: description: 1 M hydrochloric acid. hazard: corrosive - safety_class: S3 locked: true -**Read this before trusting ``safety_class`` here.** Raising a resource to S3 -changes what the bridge reports and records; it does not change what the -protocol enforces, because Labwire v0.2 gates S2 and S3 through the same -confirmation stub and its ``safety_class`` is a static property of a command -rather than a function of that command's arguments. ``locked`` is the part -that is genuinely enforced: a locked resource refuses every operation that -touches it. See ``SPEC-FINDINGS.md``. +There is no per-resource ``safety_class`` any more. It was documented in +three places as reported-but-not-enforced, and keeping a field that still +cannot raise a call's class would be keeping a lie; argument-dependent +classes are finding F3, out of scope for v0.3. What is enforced: ``locked`` +refuses every operation touching the resource, and command-level +``safety_class`` overrides now genuinely bite, because raising a command to +S3 makes it require an operator grant (SPEC 8.6). ``hazard`` appears in the +deck resource content, so an agent reading ``labwire:deck`` sees which +labware is dangerous even though the protocol cannot yet grade the call. Example: >>> AnnotationFile().version @@ -82,12 +83,7 @@ class ResourceAnnotation(_Strict): description: str | None = None hazard: str | None = None - """Free text, surfaced to agents in the deck description.""" - safety_class: SafetyClass | None = None - """The effective class for operations touching this resource. - - Reported and recorded, not enforced: see the module docstring. - """ + """Free text, surfaced to agents in the deck resource content.""" locked: bool = False """Refuse every operation that touches this resource. @@ -137,7 +133,8 @@ class AnnotationFile(_Strict): labware: dict[str, ResourceAnnotation] = {} """Keyed by PyLabRobot labware model or class name; defaults for every instance.""" resources: dict[str, ResourceAnnotation] = {} - """Keyed by the resource's own name; overrides the labware entry per field.""" + """Keyed by the labware's deck URI (``labwire:deck/``); overrides + the labware entry per field.""" @model_validator(mode="after") def _supported_version(self) -> Self: @@ -186,17 +183,17 @@ def _merge(layers: list[ResourceAnnotation]) -> ResourceAnnotation: def annotation_for( annotations: AnnotationFile, *, - name: str, + uri: str, model: str | None = None, type_name: str | None = None, ) -> ResourceAnnotation: """The annotation in force for one resource, merged per field. Layers, later winning: the labware entry keyed by PyLabRobot class, then - the one keyed by labware model, then the resource's own name. + the one keyed by labware model, then the labware's deck URI. Example: - >>> annotation_for(AnnotationFile(), name="plate").locked + >>> annotation_for(AnnotationFile(), uri="labwire:deck/plate").locked False """ layers = [ @@ -204,8 +201,8 @@ def annotation_for( for key in (type_name, model) if key is not None and key in annotations.labware ] - if name in annotations.resources: - layers.append(annotations.resources[name]) + if uri in annotations.resources: + layers.append(annotations.resources[uri]) return _merge(layers) diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py index 5da73c4..0106cbc 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py @@ -26,11 +26,11 @@ import contextlib import functools from collections.abc import Awaitable -from typing import Annotated, Any, cast +from typing import Any, cast -from labwire.bridges.pylabrobot.addressing import ADDRESS_PATTERN, resolve, resolve_all +from labwire.bridges.pylabrobot.addressing import DECK_URI, resolve, resolve_all from labwire.bridges.pylabrobot.annotations import AnnotationFile, check -from labwire.bridges.pylabrobot.deck import DeckState, deck_state, locked_labware +from labwire.bridges.pylabrobot.deck import DeckState, deck_snapshot, locked_labware from labwire.bridges.pylabrobot.introspect import command_surface, introspect from labwire.core import ( CanceledError, @@ -39,20 +39,26 @@ Instrument, InterlockError, LabwireError, + ResourceRef, + ResourceSnapshot, ValidationError, channel, command, + resource, ) -from pydantic import BaseModel, ConfigDict, StringConstraints +from pydantic import BaseModel, ConfigDict _POLL_S = 0.02 -Location = Annotated[str, StringConstraints(pattern=ADDRESS_PATTERN)] -"""An address parameter, shape-checked by JSON Schema before it is resolved. +Container = ResourceRef("container", enumerated_by=DECK_URI) +TipSite = ResourceRef("tip_site", enumerated_by=DECK_URI) +"""Typed reference parameter types (SPEC §7.2). -The pattern is all JSON Schema can say. That a well exists on *this* deck is -not expressible, so it is checked at resolution time and reported as a -validation error naming what would have worked. See SPEC-FINDINGS.md. +Until v0.3 the bridge published an invented address pattern here, which was +finding F1: a grammar satisfiable by invention and private to this bridge. +The `resource_ref` keyword replaces it. There is no pattern, so there is +nothing to invent against, and the server validates each value against a +fresh read of the deck before a handler ever runs. """ @@ -189,6 +195,30 @@ class PyLabRobotBridge(Instrument): max_concurrent_commands = 1 """A liquid handler has one arm; overlapping commands would be fiction.""" + deck = resource( + DECK_URI, + kind="deck", + title="Deck", + description=( + "What is on the deck right now: the labware standing on it, what each " + "pipetting channel holds, and the volume of every container believed to " + "hold liquid. Every container, tip site, labware and site a command " + "parameter can name is listed in this resource's index. Changes whenever " + "labware or liquid moves." + ), + content_model=DeckState, + item_kinds=[ + "labware", + "plate", + "tip_rack", + "trough", + "trash", + "container", + "tip_site", + "site", + ], + ) + tips_mounted = channel( "tips_mounted", unit="1", @@ -217,6 +247,10 @@ def __init__(self, liquid_handler: Any, annotations: AnnotationFile) -> None: # --- plumbing ---------------------------------------------------------- + @deck.reader + def _read_deck(self) -> ResourceSnapshot: + return deck_snapshot(self._lh, self._annotations) + def _publish_state(self) -> None: mounted = sum( 1 for tracker in (getattr(self._lh, "head", None) or {}).values() if tracker.has_tip @@ -224,6 +258,7 @@ def _publish_state(self) -> None: self.tips_mounted.publish(mounted) self.volume_aspirated_ul.publish(self._aspirated) self.volume_dispensed_ul.publish(self._dispensed) + self.deck.touch() def _refuse_locked(self, resources: list[Any]) -> None: """Refuse an operation that touches locked labware. @@ -279,14 +314,10 @@ def _check_lengths(self, addresses: list[str], volumes: list[float], what: str) # --- operations -------------------------------------------------------- - async def do_describe_deck(self, ctx: CommandContext) -> DeckState: - """Project the deck. Pure read, no motion.""" - return deck_state(self._lh, self._annotations) - async def do_pick_up_tips( self, ctx: CommandContext, - tip_spots: list[Location], + tip_spots: list[TipSite], # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] channels: list[int] | None = None, ) -> TipResult: """Mount tips from the named spots.""" @@ -301,7 +332,7 @@ async def do_pick_up_tips( async def do_drop_tips( self, ctx: CommandContext, - tip_spots: list[Location], + tip_spots: list[TipSite], # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] channels: list[int] | None = None, ) -> TipResult: """Drop the mounted tips at the named spots.""" @@ -326,7 +357,7 @@ async def do_discard_tips(self, ctx: CommandContext) -> TipResult: async def do_aspirate( self, ctx: CommandContext, - wells: list[Location], + wells: list[Container], # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] volumes_ul: list[float], flow_rates_ul_s: list[float] | None = None, ) -> LiquidResult: @@ -346,7 +377,7 @@ async def do_aspirate( async def do_dispense( self, ctx: CommandContext, - wells: list[Location], + wells: list[Container], # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] volumes_ul: list[float], flow_rates_ul_s: list[float] | None = None, ) -> LiquidResult: @@ -366,8 +397,8 @@ async def do_dispense( async def do_transfer( self, ctx: CommandContext, - source: Location, - targets: list[Location], + source: Container, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + targets: list[Container], # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] volumes_ul: list[float], ) -> TransferResult: """Move liquid from one well into others in a single command.""" @@ -387,7 +418,10 @@ async def do_transfer( return TransferResult(source=source, targets=targets, total_volume_ul=total) async def do_set_well_volume( - self, ctx: CommandContext, well: Location, volume_ul: float + self, + ctx: CommandContext, + well: Container, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + volume_ul: float, ) -> WellVolumeResult: """Declare how much liquid a well already holds.""" container = resolve(self._lh, well) @@ -416,7 +450,6 @@ async def do_stop(self, ctx: CommandContext) -> StopResult: _IMPLEMENTATIONS: dict[str, str] = { - "describe_deck": "do_describe_deck", "pick_up_tips": "do_pick_up_tips", "drop_tips": "do_drop_tips", "return_tips": "do_return_tips", @@ -440,19 +473,6 @@ async def do_stop(self, ctx: CommandContext) -> StopResult: } _RETURNS_UNITS: dict[str, dict[str, str]] = { - # Keyed by path, because a deck projection is a tree and a quantity three - # levels down still needs a code. "1" marks a genuine count. - "describe_deck": { - "labware[].location_mm": "mm", - "labware[].grid.rows": "1", - "labware[].grid.columns": "1", - "labware[].grid.item_max_volume_ul": "uL", - "labware[].tips_available": "1", - "channels[].index": "1", - "channels[].tip_max_volume_ul": "uL", - "contents[].volume_ul": "uL", - "contents[].max_volume_ul": "uL", - }, "pick_up_tips": {"channels_used[]": "1"}, "drop_tips": {"channels_used[]": "1"}, "return_tips": {"channels_used[]": "1"}, @@ -508,7 +528,7 @@ def PyLabRobotInstrument( check( annotations, - known_resources={item.address for item in draft.labware}, + known_resources={item.uri for item in draft.labware}, known_labware={item.type_name for item in draft.labware} | {item.model for item in draft.labware if item.model}, known_commands={spec.name for spec in surface}, diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/cli.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/cli.py index 799af83..4024ccb 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/cli.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/cli.py @@ -107,7 +107,7 @@ def check( if item.tips_available is not None: notes.append(f"{item.tips_available} tips") suffix = f" ({', '.join(notes)})" if notes else "" - typer.echo(f" {item.address}: {item.kind}{grid}{suffix}") + typer.echo(f" {item.uri}: {item.kind}{grid}{suffix}") for gap in draft.unresolved: typer.echo(f" note: {gap.message}", err=True) diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/deck.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/deck.py index ef6e379..9d5c624 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/deck.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/deck.py @@ -21,9 +21,9 @@ >>> # deck_state(lh, AnnotationFile()).channels[0].has_tip """ -from typing import Any +from typing import Annotated, Any -from labwire.bridges.pylabrobot.addressing import address_of +from labwire.bridges.pylabrobot.addressing import DECK_URI, uri_of from labwire.bridges.pylabrobot.annotations import AnnotationFile, annotation_for from labwire.bridges.pylabrobot.introspect import ( DraftLabware, @@ -32,7 +32,12 @@ addressable_resources, introspect, ) -from pydantic import BaseModel, ConfigDict +from labwire.core.messages import ResourceIndexChildren, ResourceIndexEntry +from labwire.core.server import unit_field +from pydantic import BaseModel, ConfigDict, Field + +Mm = Annotated[float, Field(json_schema_extra={"unit": "mm"})] +"""A millimetre coordinate; per-element so a tuple's items each carry a unit.""" class ChannelState(BaseModel): @@ -45,9 +50,9 @@ class ChannelState(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - index: int + index: int = unit_field("1") has_tip: bool - tip_max_volume_ul: float | None = None + tip_max_volume_ul: float | None = unit_field("uL", default=None) """Capacity of the mounted tip, which bounds a single aspiration.""" @@ -58,15 +63,15 @@ class WellContents(BaseModel): has been told and what it has moved, and cannot see into a plate. Example: - >>> WellContents(address="plate/A1", volume_ul=200.0).volume_ul + >>> WellContents(uri="labwire:deck/plate/A1", volume_ul=200.0).volume_ul 200.0 """ model_config = ConfigDict(frozen=True, extra="forbid") - address: str - volume_ul: float - max_volume_ul: float | None = None + uri: str + volume_ul: float = unit_field("uL") + max_volume_ul: float | None = unit_field("uL", default=None) class LabwareState(BaseModel): @@ -78,20 +83,22 @@ class LabwareState(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - address: str + uri: str + kinds: list[str] + """Registered kinds this labware satisfies, most specific first; empty + for deck furniture the bridge cannot classify (visible, not + referenceable).""" kind: LabwareKind type_name: str model: str | None = None - location_mm: tuple[float, float, float] | None = None + location_mm: tuple[Mm, Mm, Mm] | None = None grid: Grid | None = None description: str | None = None hazard: str | None = None """What the annotation file says this holds, surfaced so an agent can see it.""" - safety_class: str | None = None - """Effective class for operations touching this labware, when annotated.""" locked: bool = False """Operations touching this labware are refused outright.""" - tips_available: int | None = None + tips_available: int | None = unit_field("1", default=None) """For a tip rack: how many spots still hold a tip.""" @@ -109,16 +116,16 @@ class DeckState(BaseModel): contents: list[WellContents] """Only wells believed to hold liquid; an empty deck projects an empty list.""" - def find(self, address: str) -> LabwareState: - """Look up labware by address. + def find(self, uri: str) -> LabwareState: + """Look up labware by URI. Example: - >>> # deck_state(lh, annotations).find("source_plate").kind + >>> # deck_state(lh, annotations).find("labwire:deck/source_plate").kind """ for candidate in self.labware: - if candidate.address == address: + if candidate.uri == uri: return candidate - raise KeyError(f"no such labware: {address!r}") + raise KeyError(f"no such labware: {uri!r}") def _volume_of(well: Any) -> float | None: @@ -161,12 +168,13 @@ def _channel_states(liquid_handler: Any) -> list[ChannelState]: def _labware_state(resource: Any, draft: DraftLabware, annotations: AnnotationFile) -> LabwareState: annotation = annotation_for( annotations, - name=draft.address, + uri=draft.uri, model=draft.model, type_name=draft.type_name, ) return LabwareState( - address=draft.address, + uri=draft.uri, + kinds=draft.kind.kinds(), kind=draft.kind, type_name=draft.type_name, model=draft.model, @@ -174,7 +182,6 @@ def _labware_state(resource: Any, draft: DraftLabware, annotations: AnnotationFi grid=draft.grid, description=annotation.description, hazard=annotation.hazard, - safety_class=annotation.safety_class, locked=annotation.locked, tips_available=(_tips_available(resource) if draft.kind is LabwareKind.TIP_RACK else None), ) @@ -188,12 +195,12 @@ def deck_state(liquid_handler: Any, annotations: AnnotationFile | None = None) - """ annotations = annotations or AnnotationFile() draft = introspect(liquid_handler) - by_address = {item.address: item for item in draft.labware} + by_uri = {item.uri: item for item in draft.labware} labware: list[LabwareState] = [] contents: list[WellContents] = [] for resource in addressable_resources(liquid_handler): - described = by_address.get(address_of(resource)) + described = by_uri.get(uri_of(resource)) if described is None: # pragma: no cover - introspect covers the same set continue labware.append(_labware_state(resource, described, annotations)) @@ -206,7 +213,7 @@ def deck_state(liquid_handler: Any, annotations: AnnotationFile | None = None) - maximum = getattr(item, "max_volume", None) contents.append( WellContents( - address=address_of(item), + uri=uri_of(item), volume_ul=volume, max_volume_ul=float(maximum) if isinstance(maximum, int | float) else None, ) @@ -219,6 +226,56 @@ def deck_state(liquid_handler: Any, annotations: AnnotationFile | None = None) - ) +def deck_index(liquid_handler: Any) -> list[ResourceIndexEntry]: + """The reference index of ``labwire:deck`` (SPEC §10.2). + + Every container, tip site, labware, and site a command parameter can name + is here; deck furniture the bridge cannot classify stays in content but + out of the index, so it is visible without being referenceable. + + Example: + >>> # deck_index(lh)[0].uri + """ + entries: list[ResourceIndexEntry] = [] + for item in introspect(liquid_handler).labware: + kinds = item.kind.kinds() + if not kinds: + continue # unclassifiable furniture is not a reference target + children = None + if item.grid is not None and item.kind in (LabwareKind.PLATE, LabwareKind.TIP_RACK): + child_kinds = ["tip_site"] if item.kind is LabwareKind.TIP_RACK else ["container"] + rows, columns = item.grid.rows, item.grid.columns + ids = [ + f"{chr(ord('A') + row)}{column + 1}" + for column in range(columns) + for row in range(rows) + ] + children = ResourceIndexChildren(kinds=child_kinds, ids=ids) + entries.append( + ResourceIndexEntry( + uri=item.uri, + kinds=kinds, + title=item.uri.rsplit("/", 1)[1], + children=children, + ) + ) + return entries + + +def deck_snapshot(liquid_handler: Any, annotations: AnnotationFile | None = None) -> Any: + """Index and content together, for the resource reader. + + Example: + >>> # ResourceSnapshot-shaped: deck_snapshot(lh).content + """ + from labwire.core import ResourceSnapshot + + return ResourceSnapshot( + index=deck_index(liquid_handler), + content=deck_state(liquid_handler, annotations), + ) + + def locked_labware(annotations: AnnotationFile, resources: list[Any]) -> list[str]: """Names of the given resources that an annotation has locked. @@ -237,7 +294,7 @@ def locked_labware(annotations: AnnotationFile, resources: list[Any]) -> list[st owner = parent annotation = annotation_for( annotations, - name=str(owner.name), + uri=f"{DECK_URI}/{owner.name}", model=getattr(owner, "model", None), type_name=type(owner).__name__, ) diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py index 221347c..d3218f8 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py @@ -22,8 +22,9 @@ import enum from typing import Any -from labwire.bridges.pylabrobot.addressing import address_of +from labwire.bridges.pylabrobot.addressing import uri_of from labwire.core import IdentityInfo, SafetyClass +from labwire.core.server import unit_field from pydantic import BaseModel, ConfigDict _CATEGORY_KINDS = { @@ -34,9 +35,23 @@ "carrier": "carrier", "plate_carrier": "carrier", "tip_carrier": "carrier", + "plate_holder": "site", "deck": "deck", } +KIND_SETS: dict[str, list[str]] = { + # SPEC Appendix A: an index entry lists every kind it satisfies, most + # specific first, so a reference declaring any of them resolves to it. + "plate": ["plate", "labware"], + "tip_rack": ["tip_rack", "labware"], + "trough": ["trough", "container", "labware"], + "trash": ["trash", "labware"], + "site": ["site"], + "carrier": ["labware"], +} +"""Registered kind arrays per bridge classification; OTHER maps to none and +is deliberately not referenceable.""" + class LabwareKind(enum.StrEnum): """What a piece of labware is, as far as the bridge can tell.""" @@ -46,9 +61,19 @@ class LabwareKind(enum.StrEnum): TROUGH = "trough" TRASH = "trash" CARRIER = "carrier" + SITE = "site" DECK = "deck" OTHER = "other" - """Recognized as present and addressable, but of unknown purpose.""" + """Recognized as present, but of unknown purpose and not referenceable.""" + + def kinds(self) -> list[str]: + """The registered kind array this classification satisfies. + + Example: + >>> LabwareKind.TROUGH.kinds() + ['trough', 'container', 'labware'] + """ + return list(KIND_SETS.get(self.value, [])) class UnresolvedReason(enum.StrEnum): @@ -72,9 +97,9 @@ class Grid(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - rows: int - columns: int - item_max_volume_ul: float | None = None + rows: int = unit_field("1") + columns: int = unit_field("1") + item_max_volume_ul: float | None = unit_field("uL", default=None) @property def item_count(self) -> int: @@ -91,14 +116,14 @@ class DraftLabware(BaseModel): """One addressable piece of labware on the deck. Example: - >>> # draft.labware[0].address - >>> # 'source_plate' + >>> # draft.labware[0].uri + >>> # 'labwire:deck/source_plate' """ model_config = ConfigDict(frozen=True, extra="forbid") - address: str - """The name this labware is addressed by (also its PyLabRobot name).""" + uri: str + """The labware's deck URI, ``labwire:deck/`` (SPEC §10.1).""" kind: LabwareKind type_name: str """The PyLabRobot class, e.g. ``Plate``.""" @@ -132,7 +157,7 @@ class Unresolved(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - address: str + uri: str reason: UnresolvedReason message: str @@ -162,16 +187,16 @@ def is_complete(self) -> bool: """ return not self.unresolved - def find(self, address: str) -> DraftLabware: - """Look up labware by address. + def find(self, uri: str) -> DraftLabware: + """Look up labware by URI. Example: - >>> # draft.find("source_plate").kind + >>> # draft.find("labwire:deck/source_plate").kind """ for candidate in self.labware: - if candidate.address == address: + if candidate.uri == uri: return candidate - raise KeyError(f"no such labware: {address!r}") + raise KeyError(f"no such labware: {uri!r}") def _pylabrobot_version() -> str: @@ -235,7 +260,7 @@ def addressable_resources(root: Any) -> list[Any]: def _describe_labware(resource: Any) -> tuple[DraftLabware, list[Unresolved]]: - address = address_of(resource) + uri = uri_of(resource) kind = _kind_of(resource) grid = _grid_of(resource) try: @@ -247,10 +272,10 @@ def _describe_labware(resource: Any) -> tuple[DraftLabware, list[Unresolved]]: if kind is LabwareKind.OTHER: gaps.append( Unresolved( - address=address, + uri=uri, reason=UnresolvedReason.UNKNOWN_KIND, message=( - f"{address!r} is a {type(resource).__name__} with category " + f"{uri!r} is a {type(resource).__name__} with category " f"{getattr(resource, 'category', None)!r}, which the bridge does not " "recognize; it stays addressable but its purpose is not described" ), @@ -259,10 +284,10 @@ def _describe_labware(resource: Any) -> tuple[DraftLabware, list[Unresolved]]: if location is None: gaps.append( Unresolved( - address=address, + uri=uri, reason=UnresolvedReason.NO_LOCATION, message=( - f"{address!r} has no location on the deck, so an agent cannot reason " + f"{uri!r} has no location on the deck, so an agent cannot reason " "about where it is; assign it before serving" ), ) @@ -270,17 +295,17 @@ def _describe_labware(resource: Any) -> tuple[DraftLabware, list[Unresolved]]: if grid is not None and grid.item_max_volume_ul is None and kind is not LabwareKind.TIP_RACK: gaps.append( Unresolved( - address=address, + uri=uri, reason=UnresolvedReason.NO_CAPACITY, message=( - f"{address!r} has {grid.item_count} items that report no maximum volume, " + f"{uri!r} has {grid.item_count} items that report no maximum volume, " "so overfilling cannot be caught before it happens" ), ) ) labware = DraftLabware( - address=address, + uri=uri, kind=kind, type_name=type(resource).__name__, model=getattr(resource, "model", None), @@ -301,22 +326,15 @@ def command_surface() -> list[DraftCommand]: Fixed rather than derived, unlike the ophyd bridge: PyLabRobot's frontend is one class with one set of operations, so there is nothing to discover. Safety classes follow ``DESIGN.md``: anything that moves or consumes - material is S2, reads are S0, and ``stop`` is S0 so recovery stays - available while an interlock is tripped. + material is S2, and ``stop`` is S0 so recovery stays available while an + interlock is tripped. There is no ``describe_deck``: the deck is the + ``labwire:deck`` resource now (SPEC §10), read rather than commanded. Example: >>> next(c.safety_class for c in command_surface() if c.name == "aspirate") 'S2' """ return [ - DraftCommand( - name="describe_deck", - description=( - "List the labware on the deck, the state of each pipetting channel, and " - "the volume of every well known to hold liquid." - ), - safety_class="S0", - ), DraftCommand( name="pick_up_tips", description="Pick up tips from the given tip spots onto the pipetting channels.", diff --git a/packages/bridges/pylabrobot/tests/test_addressing.py b/packages/bridges/pylabrobot/tests/test_addressing.py index 8d73b21..5d28117 100644 --- a/packages/bridges/pylabrobot/tests/test_addressing.py +++ b/packages/bridges/pylabrobot/tests/test_addressing.py @@ -1,85 +1,78 @@ import pytest -from labwire.bridges.pylabrobot import Address, address_of, resolve, resolve_all +from labwire.bridges.pylabrobot import resolve, resolve_all, split_deck_uri, uri_of from labwire.core.errors import ValidationError from pylabrobot.liquid_handling import LiquidHandler -# --- the grammar ------------------------------------------------------------ +# --- the URI grammar (SPEC 10.1: one protocol-defined composition rule) ----- @pytest.mark.parametrize( - ("text", "labware", "item"), + ("uri", "labware", "item"), [ - ("plate", "plate", None), - ("source_plate/A1", "source_plate", "A1"), - ("tips/H12", "tips", "H12"), - ("plate-2/A1", "plate-2", "A1"), - ("plate.left/B7", "plate.left", "B7"), + ("labwire:deck/plate", "plate", None), + ("labwire:deck/source_plate/A1", "source_plate", "A1"), + ("labwire:deck/tips/H12", "tips", "H12"), + ("labwire:deck/plate-2/A1", "plate-2", "A1"), ], ) -def test_well_formed_addresses_parse(text: str, labware: str, item: str | None) -> None: - parsed = Address.parse(text) - assert (parsed.labware, parsed.item) == (labware, item) - assert str(parsed) == text # round trips +def test_well_formed_uris_split(uri: str, labware: str, item: str | None) -> None: + assert split_deck_uri(uri) == (labware, item) @pytest.mark.parametrize( - "text", + "uri", [ "", - "/A1", # no labware - "plate/", # trailing separator - "plate/A1/B2", # two levels - "plate/A1:H1", # PyLabRobot's range syntax is deliberately not accepted - "plate A1", - "_plate/A1", # must start alphanumeric - "plate/A 1", + "labwire:deck", # the resource itself is not an item reference + "labwire:deck/", # empty segment + "labwire:deck/plate/", # trailing separator + "labwire:deck/plate/A1/B2", # too deep + "labwire:deck//A1", # empty labware + "plate/A1", # the pre-v0.3 bridge-private grammar is gone + "source_plate", + "labwire:freezer/plate", # a resource this bridge does not declare ], ) -def test_malformed_addresses_are_rejected_with_an_example(text: str) -> None: - with pytest.raises(ValidationError, match="source_plate/A1"): - Address.parse(text) - - -def test_a_non_string_is_rejected_rather_than_coerced() -> None: - with pytest.raises(ValidationError, match="malformed address"): - Address.parse(7) # pyright: ignore[reportArgumentType] +def test_malformed_uris_are_rejected_with_an_example(uri: str) -> None: + with pytest.raises(ValidationError, match="labwire:deck/source_plate/A1"): + split_deck_uri(uri) # --- resolution ------------------------------------------------------------- async def test_labware_resolves_to_the_resource_itself(rig: LiquidHandler) -> None: - assert resolve(rig, "source_plate").name == "source_plate" + assert resolve(rig, "labwire:deck/source_plate").name == "source_plate" async def test_an_item_resolves_to_the_well(rig: LiquidHandler) -> None: - well = resolve(rig, "source_plate/A1") + well = resolve(rig, "labwire:deck/source_plate/A1") assert well.name == "source_plate_well_A1" assert well.max_volume == 360 async def test_a_tip_spot_resolves(rig: LiquidHandler) -> None: - assert resolve(rig, "tips/A1").tracker.has_tip is True + assert resolve(rig, "labwire:deck/tips/A1").tracker.has_tip is True async def test_the_last_item_of_the_grid_resolves(rig: LiquidHandler) -> None: """H12 is index 95 of a 96-well plate: the corner most likely to be off by one.""" - assert resolve(rig, "source_plate/H12").name == "source_plate_well_H12" + assert resolve(rig, "labwire:deck/source_plate/H12").name == "source_plate_well_H12" async def test_an_unknown_labware_error_lists_what_is_there(rig: LiquidHandler) -> None: with pytest.raises(ValidationError) as caught: - resolve(rig, "nonexistent_plate") + resolve(rig, "labwire:deck/nonexistent_plate") message = str(caught.value) assert "nonexistent_plate" in message - assert "source_plate" in message # what would have worked - assert "tips" in message + assert "labwire:deck/source_plate" in message # what would have worked + assert "labwire:deck/tips" in message assert "source_plate_well_A1" not in message # wells are not listed as labware async def test_an_unknown_item_error_reports_the_grid_shape(rig: LiquidHandler) -> None: with pytest.raises(ValidationError) as caught: - resolve(rig, "source_plate/Z99") + resolve(rig, "labwire:deck/source_plate/Z99") assert "8 rows by 12 columns" in str(caught.value) @@ -87,67 +80,59 @@ async def test_addressing_an_item_of_something_itemless_explains_itself( rig: LiquidHandler, ) -> None: with pytest.raises(ValidationError) as caught: - resolve(rig, "trash/A1") + resolve(rig, "labwire:deck/trash/A1") message = str(caught.value) assert "no addressable items" in message - assert "'trash'" in message # tells you what to say instead - - -def test_an_unloaded_deck_still_lists_its_built_in_labware(bare_deck: object) -> None: - """A STARlet deck is never truly empty: it ships with a trash and grippers.""" - with pytest.raises(ValidationError) as caught: - resolve(bare_deck, "source_plate") - assert "trash" in str(caught.value) - - -def test_a_deck_with_nothing_at_all_says_none_assigned() -> None: - from pylabrobot.resources import Deck - - empty = Deck(name="empty", size_x=500.0, size_y=500.0, size_z=100.0) - with pytest.raises(ValidationError, match=r"none assigned"): - resolve(empty, "source_plate") + assert "labwire:deck/trash" in message # tells you what to say instead -async def test_pylabrobots_own_derived_names_are_refused_with_the_canonical_one( +async def test_pylabrobots_own_derived_names_are_refused_with_the_canonical_uri( rig: LiquidHandler, ) -> None: """PyLabRobot resolves derived names; the bridge refuses them, helpfully. ``get_resource`` searches the whole subtree, so ``source_plate_well_A1`` - would otherwise be a second spelling of ``source_plate/A1``. + would otherwise be a second spelling of ``.../source_plate/A1``. """ with pytest.raises(ValidationError) as caught: - resolve(rig, "source_plate_well_A1") - assert "'source_plate/A1'" in str(caught.value) # says what to use instead + resolve(rig, "labwire:deck/source_plate_well_A1") + assert "'labwire:deck/source_plate/A1'" in str(caught.value) async def test_resolve_all_preserves_order(rig: LiquidHandler) -> None: - wells = resolve_all(rig, ["source_plate/B1", "source_plate/A1"]) + wells = resolve_all(rig, ["labwire:deck/source_plate/B1", "labwire:deck/source_plate/A1"]) assert [w.name for w in wells] == ["source_plate_well_B1", "source_plate_well_A1"] -async def test_resolve_all_fails_on_the_first_bad_address(rig: LiquidHandler) -> None: +async def test_resolve_all_fails_on_the_first_bad_uri(rig: LiquidHandler) -> None: with pytest.raises(ValidationError, match="Z99"): - resolve_all(rig, ["source_plate/A1", "source_plate/Z99", "source_plate/B1"]) + resolve_all( + rig, + [ + "labwire:deck/source_plate/A1", + "labwire:deck/source_plate/Z99", + "labwire:deck/source_plate/B1", + ], + ) # --- the reverse direction -------------------------------------------------- -async def test_address_of_a_well_is_the_address_that_resolves_back_to_it( +async def test_uri_of_a_well_is_the_uri_that_resolves_back_to_it( rig: LiquidHandler, ) -> None: - well = resolve(rig, "source_plate/C4") - assert address_of(well) == "source_plate/C4" - assert resolve(rig, address_of(well)) is well + well = resolve(rig, "labwire:deck/source_plate/C4") + assert uri_of(well) == "labwire:deck/source_plate/C4" + assert resolve(rig, uri_of(well)) is well -async def test_address_of_labware_is_its_name(rig: LiquidHandler) -> None: - assert address_of(resolve(rig, "tips")) == "tips" +async def test_uri_of_labware_composes_from_its_name(rig: LiquidHandler) -> None: + assert uri_of(resolve(rig, "labwire:deck/tips")) == "labwire:deck/tips" async def test_every_well_of_a_plate_round_trips(rig: LiquidHandler) -> None: - """The address grammar has to hold for all 96, not just the corners.""" - plate = resolve(rig, "source_plate") + """The composition rule has to hold for all 96, not just the corners.""" + plate = resolve(rig, "labwire:deck/source_plate") for well in plate.get_all_items(): - assert resolve(rig, address_of(well)) is well + assert resolve(rig, uri_of(well)) is well diff --git a/packages/bridges/pylabrobot/tests/test_deck_introspection.py b/packages/bridges/pylabrobot/tests/test_deck_introspection.py index 7373c5c..75b0d13 100644 --- a/packages/bridges/pylabrobot/tests/test_deck_introspection.py +++ b/packages/bridges/pylabrobot/tests/test_deck_introspection.py @@ -37,24 +37,24 @@ async def test_channel_count_is_available_before_setup() -> None: async def test_assigned_labware_is_found_and_classified(rig: LiquidHandler) -> None: draft = introspect(rig) - assert draft.find("source_plate").kind is LabwareKind.PLATE - assert draft.find("tips").kind is LabwareKind.TIP_RACK - assert draft.find("trash").kind is LabwareKind.TRASH + assert draft.find("labwire:deck/source_plate").kind is LabwareKind.PLATE + assert draft.find("labwire:deck/tips").kind is LabwareKind.TIP_RACK + assert draft.find("labwire:deck/trash").kind is LabwareKind.TRASH async def test_a_plate_reports_its_grid_and_well_capacity(rig: LiquidHandler) -> None: - grid = introspect(rig).find("source_plate").grid + grid = introspect(rig).find("labwire:deck/source_plate").grid assert grid is not None assert grid == Grid(rows=8, columns=12, item_max_volume_ul=360.0) assert grid.item_count == 96 async def test_labware_carries_its_pylabrobot_model(rig: LiquidHandler) -> None: - assert introspect(rig).find("source_plate").model == "Cor_96_wellplate_360ul_Fb" + assert introspect(rig).find("labwire:deck/source_plate").model == "Cor_96_wellplate_360ul_Fb" async def test_labware_reports_where_it_is_and_how_big_it_is(rig: LiquidHandler) -> None: - plate = introspect(rig).find("source_plate") + plate = introspect(rig).find("labwire:deck/source_plate") assert plate.location_mm is not None assert plate.location_mm[0] > 0 assert plate.size_mm == (127.76, 85.48, 14.2) # a standard SBS footprint @@ -62,9 +62,9 @@ async def test_labware_reports_where_it_is_and_how_big_it_is(rig: LiquidHandler) async def test_wells_are_not_listed_as_labware(rig: LiquidHandler) -> None: """The projection lists what you address, not all 208 resources on the deck.""" - addresses = {item.address for item in introspect(rig).labware} - assert "source_plate" in addresses - assert not any("/" in address for address in addresses) + addresses = {item.uri for item in introspect(rig).labware} + assert "labwire:deck/source_plate" in addresses + assert not any(address.count("/") > 1 for address in addresses) # no wells assert len(addresses) < 15 # the raw tree has 200+ resources @@ -81,7 +81,7 @@ async def test_the_projection_is_small_enough_to_give_an_agent(rig: LiquidHandle async def test_the_labware_a_user_loaded_introspects_cleanly(rig: LiquidHandler) -> None: """Nothing is reported against the plates and tips actually being used.""" - flagged = {gap.address for gap in introspect(rig).unresolved} + flagged = {gap.uri for gap in introspect(rig).unresolved} assert not (flagged & {"source_plate", "target_plate", "tips"}) @@ -95,7 +95,10 @@ async def test_even_a_stock_deck_has_furniture_the_bridge_cannot_classify( """ draft = introspect(rig) assert not draft.is_complete - assert {gap.address for gap in draft.unresolved} == {"waste_block", "core_grippers"} + assert {gap.uri for gap in draft.unresolved} == { + "labwire:deck/waste_block", + "labwire:deck/core_grippers", + } assert all(g.reason is UnresolvedReason.UNKNOWN_KIND for g in draft.unresolved) @@ -112,7 +115,9 @@ async def test_labware_with_no_location_is_reported() -> None: handler = LiquidHandler(backend=LiquidHandlerChatterboxBackend(num_channels=1), deck=deck) deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="unplaced_plate"), location=None) - gaps = [gap for gap in introspect(handler).unresolved if gap.address == "unplaced_plate"] + gaps = [ + gap for gap in introspect(handler).unresolved if gap.uri == "labwire:deck/unplaced_plate" + ] assert UnresolvedReason.NO_LOCATION in {gap.reason for gap in gaps} assert any("assign it before serving" in gap.message for gap in gaps) @@ -120,9 +125,9 @@ async def test_labware_with_no_location_is_reported() -> None: async def test_unrecognized_labware_stays_addressable_and_is_flagged(rig: LiquidHandler) -> None: """The STARlet's waste block has no category PyLabRobot names.""" draft = introspect(rig) - block = draft.find("waste_block") + block = draft.find("labwire:deck/waste_block") assert block.kind is LabwareKind.OTHER - reasons = {g.reason for g in draft.unresolved if g.address == "waste_block"} + reasons = {g.reason for g in draft.unresolved if g.uri == "labwire:deck/waste_block"} assert UnresolvedReason.UNKNOWN_KIND in reasons @@ -141,11 +146,15 @@ def test_material_moving_commands_are_s2() -> None: assert classes[name] == "S2", name -def test_reads_and_stop_are_s0() -> None: - """stop must stay submittable while an interlock is tripped (SPEC 8.6).""" +def test_stop_is_s0_and_the_deck_is_not_a_command() -> None: + """stop must stay submittable while an interlock is tripped (SPEC 8.6). + + describe_deck is gone: the deck is the labwire:deck resource now, read + rather than commanded, so nothing marks it S-anything. + """ classes = {c.name: c.safety_class for c in command_surface()} assert classes["stop"] == "S0" - assert classes["describe_deck"] == "S0" + assert "describe_deck" not in classes def test_declaring_a_wells_contents_is_s1_because_it_moves_nothing() -> None: @@ -154,6 +163,11 @@ def test_declaring_a_wells_contents_is_s1_because_it_moves_nothing() -> None: assert classes["set_well_volume"] == "S1" +def test_the_command_surface_has_nine_operations() -> None: + """Ten in v0.2; describe_deck became the deck resource.""" + assert len(command_surface()) == 9 + + def test_the_untyped_backend_passthrough_is_not_exposed() -> None: """Every PyLabRobot operation takes **backend_kwargs straight to vendor firmware.""" names = {c.name for c in command_surface()} diff --git a/packages/bridges/pylabrobot/tests/test_deck_state.py b/packages/bridges/pylabrobot/tests/test_deck_state.py index ef07eff..9f4ac56 100644 --- a/packages/bridges/pylabrobot/tests/test_deck_state.py +++ b/packages/bridges/pylabrobot/tests/test_deck_state.py @@ -26,10 +26,10 @@ async def test_a_fresh_deck_reports_no_liquid_at_all(rig: LiquidHandler) -> None async def test_a_well_with_liquid_appears_with_its_address(rig: LiquidHandler) -> None: - resolve(rig, "source_plate/A1").tracker.set_volume(200.0) + resolve(rig, "labwire:deck/source_plate/A1").tracker.set_volume(200.0) contents = deck_state(rig).contents assert len(contents) == 1 - assert contents[0].address == "source_plate/A1" + assert contents[0].uri == "labwire:deck/source_plate/A1" assert contents[0].volume_ul == 200.0 assert contents[0].max_volume_ul == 360.0 @@ -39,21 +39,21 @@ async def test_channels_report_whether_they_hold_a_tip(rig: LiquidHandler) -> No assert len(before) == 8 assert not any(channel.has_tip for channel in before) - await rig.pick_up_tips(resolve(rig, "tips").get_items(["A1", "B1"])) + await rig.pick_up_tips(resolve(rig, "labwire:deck/tips").get_items(["A1", "B1"])) after = deck_state(rig).channels assert [channel.has_tip for channel in after[:3]] == [True, True, False] assert after[0].tip_max_volume_ul == 1065.0 # bounds a single aspiration async def test_a_tip_rack_reports_how_many_tips_are_left(rig: LiquidHandler) -> None: - assert deck_state(rig).find("tips").tips_available == 96 - await rig.pick_up_tips(resolve(rig, "tips").get_items(["A1", "B1"])) - assert deck_state(rig).find("tips").tips_available == 94 + assert deck_state(rig).find("labwire:deck/tips").tips_available == 96 + await rig.pick_up_tips(resolve(rig, "labwire:deck/tips").get_items(["A1", "B1"])) + assert deck_state(rig).find("labwire:deck/tips").tips_available == 94 async def test_the_projection_stays_small_with_a_deck_in_use(rig: LiquidHandler) -> None: for row in "ABCDEFGH": - resolve(rig, f"source_plate/{row}1").tracker.set_volume(300.0) + resolve(rig, f"labwire:deck/source_plate/{row}1").tracker.set_volume(300.0) projected = json.dumps(deck_state(rig).model_dump(mode="json")) assert len(json.dumps(rig.serialize())) > 100_000 assert len(projected) < 8_000 @@ -61,8 +61,8 @@ async def test_the_projection_stays_small_with_a_deck_in_use(rig: LiquidHandler) async def test_tip_racks_do_not_report_well_contents(rig: LiquidHandler) -> None: """A tip spot is not a container; counting tips is the useful projection.""" - addresses = {well.address for well in deck_state(rig).contents} - assert not any(address.startswith("tips/") for address in addresses) + addresses = {well.uri for well in deck_state(rig).contents} + assert not any(address.startswith("labwire:deck/tips/") for address in addresses) # --- annotations ------------------------------------------------------------ @@ -114,17 +114,16 @@ def test_a_full_file_loads(tmp_path: Path) -> None: labware: Cor_96_wellplate_360ul_Fb: {description: A Costar 96-well plate.} resources: - source_plate: + labwire:deck/source_plate: description: 1 M hydrochloric acid. hazard: corrosive - safety_class: S3 locked: true """, ) ) assert annotations.instrument.intent_tags == ["liquid_handling"] assert annotations.commands["transfer"].estimated_duration_s == 4.0 - assert annotations.resources["source_plate"].hazard == "corrosive" + assert annotations.resources["labwire:deck/source_plate"].hazard == "corrosive" def test_resource_entries_override_labware_entries_field_by_field() -> None: @@ -132,17 +131,20 @@ def test_resource_entries_override_labware_entries_field_by_field() -> None: labware={ "Cor_96_wellplate_360ul_Fb": ResourceAnnotation(description="A plate.", hazard="none") }, - resources={"acid_stock": ResourceAnnotation(hazard="corrosive")}, + resources={"labwire:deck/acid_stock": ResourceAnnotation(hazard="corrosive")}, ) merged = annotation_for( - annotations, name="acid_stock", model="Cor_96_wellplate_360ul_Fb", type_name="Plate" + annotations, + uri="labwire:deck/acid_stock", + model="Cor_96_wellplate_360ul_Fb", + type_name="Plate", ) assert merged.hazard == "corrosive" # the resource entry wins assert merged.description == "A plate." # untouched fields survive def test_an_unannotated_resource_gets_harmless_defaults() -> None: - merged = annotation_for(AnnotationFile(), name="whatever") + merged = annotation_for(AnnotationFile(), uri="labwire:deck/whatever") assert merged.locked is False assert merged.hazard is None @@ -156,8 +158,10 @@ async def test_an_annotation_naming_a_resource_that_is_not_there_is_refused( state = deck_state(rig) with pytest.raises(AnnotationError, match="acid_stock"): check( - AnnotationFile(resources={"acid_stock": ResourceAnnotation(hazard="corrosive")}), - known_resources={item.address for item in state.labware}, + AnnotationFile( + resources={"labwire:deck/acid_stock": ResourceAnnotation(hazard="corrosive")} + ), + known_resources={item.uri for item in state.labware}, known_labware={item.type_name for item in state.labware}, known_commands={c.name for c in command_surface()}, ) @@ -195,23 +199,27 @@ async def test_a_hazard_annotation_reaches_the_deck_projection(rig: LiquidHandle """An agent has to be able to see what it is about to pipette.""" annotations = AnnotationFile( resources={ - "source_plate": ResourceAnnotation(hazard="corrosive", safety_class="S3"), + "labwire:deck/source_plate": ResourceAnnotation(hazard="corrosive"), } ) - plate = deck_state(rig, annotations).find("source_plate") + plate = deck_state(rig, annotations).find("labwire:deck/source_plate") assert plate.hazard == "corrosive" - assert plate.safety_class == "S3" async def test_locking_a_plate_locks_every_well_of_it(rig: LiquidHandler) -> None: """Locking is checked through the parent, so nobody names 96 wells.""" - annotations = AnnotationFile(resources={"source_plate": ResourceAnnotation(locked=True)}) - wells = [resolve(rig, "source_plate/A1"), resolve(rig, "source_plate/H12")] + annotations = AnnotationFile( + resources={"labwire:deck/source_plate": ResourceAnnotation(locked=True)} + ) + wells = [ + resolve(rig, "labwire:deck/source_plate/A1"), + resolve(rig, "labwire:deck/source_plate/H12"), + ] assert locked_labware(annotations, wells) == ["source_plate"] async def test_an_unlocked_plate_reports_nothing_locked(rig: LiquidHandler) -> None: - wells = [resolve(rig, "source_plate/A1")] + wells = [resolve(rig, "labwire:deck/source_plate/A1")] assert locked_labware(AnnotationFile(), wells) == [] @@ -219,11 +227,14 @@ async def test_locking_by_labware_model_covers_every_instance(rig: LiquidHandler annotations = AnnotationFile( labware={"Cor_96_wellplate_360ul_Fb": ResourceAnnotation(locked=True)} ) - wells = [resolve(rig, "source_plate/A1"), resolve(rig, "target_plate/A1")] + wells = [ + resolve(rig, "labwire:deck/source_plate/A1"), + resolve(rig, "labwire:deck/target_plate/A1"), + ] assert sorted(locked_labware(annotations, wells)) == ["source_plate", "target_plate"] async def test_labware_kinds_survive_annotation(rig: LiquidHandler) -> None: state = deck_state(rig, AnnotationFile()) - assert state.find("tips").kind is LabwareKind.TIP_RACK - assert state.find("source_plate").kind is LabwareKind.PLATE + assert state.find("labwire:deck/tips").kind is LabwareKind.TIP_RACK + assert state.find("labwire:deck/source_plate").kind is LabwareKind.PLATE diff --git a/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py b/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py index a588d3f..d6de6ae 100644 --- a/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py +++ b/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py @@ -54,14 +54,30 @@ async def test_the_instrument_describes_itself_with_units_and_safety( } -async def test_address_parameters_carry_a_pattern( +async def test_address_parameters_carry_typed_references_and_no_pattern( served: tuple[LiquidHandler, LabwireClient], ) -> None: - """All JSON Schema can say about a reference is its shape.""" + """The F1 fix: the reference declaration replaced the invented pattern. + + A pattern is satisfiable by invention; resource_ref points at the deck + index instead, and rides inside the schema that travels to agents. + """ _rig, client = served descriptor = await client.describe() schema = next(c for c in descriptor.commands if c.name == "aspirate").params_schema - assert "pattern" in schema["properties"]["wells"]["items"] + items = schema["properties"]["wells"]["items"] + assert "pattern" not in items + assert items["resource_ref"] == {"kind": "container", "enumerated_by": "labwire:deck"} + assert "labwire:deck" in items["description"] # the pointer an agent reads + + +async def test_the_descriptor_declares_the_deck_resource( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + _rig, client = served + descriptor = await client.describe() + assert [r.uri for r in descriptor.resources] == ["labwire:deck"] + assert "container" in descriptor.resources[0].item_kinds async def test_optional_parameters_are_not_required( @@ -81,43 +97,51 @@ async def test_a_full_transfer_runs_through_the_protocol( ) -> None: """Tips on, aspirate, dispense, tips off, with the deck read at each step.""" _rig, client = served - await _call(client, "set_well_volume", {"well": "source_plate/A1", "volume_ul": 300.0}) + await _call( + client, "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": 300.0} + ) - await _call(client, "pick_up_tips", {"tip_spots": ["tips/A1"]}) - state = await _call(client, "describe_deck", {}) - assert state["channels"][0]["has_tip"] is True + await _call(client, "pick_up_tips", {"tip_spots": ["labwire:deck/tips/A1"]}) + snapshot = await client.read_resource("labwire:deck") + assert snapshot.content["channels"][0]["has_tip"] is True - await _call(client, "aspirate", {"wells": ["source_plate/A1"], "volumes_ul": [100.0]}) - await _call(client, "dispense", {"wells": ["target_plate/A1"], "volumes_ul": [100.0]}) + await _call( + client, "aspirate", {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [100.0]} + ) + await _call( + client, "dispense", {"wells": ["labwire:deck/target_plate/A1"], "volumes_ul": [100.0]} + ) await _call(client, "return_tips", {}) - state = await _call(client, "describe_deck", {}) - volumes = {well["address"]: well["volume_ul"] for well in state["contents"]} - assert volumes["source_plate/A1"] == 200.0 - assert volumes["target_plate/A1"] == 100.0 - assert state["channels"][0]["has_tip"] is False + snapshot = await client.read_resource("labwire:deck") + volumes = {well["uri"]: well["volume_ul"] for well in snapshot.content["contents"]} + assert volumes["labwire:deck/source_plate/A1"] == 200.0 + assert volumes["labwire:deck/target_plate/A1"] == 100.0 + assert snapshot.content["channels"][0]["has_tip"] is False async def test_transfer_moves_liquid_into_several_wells( served: tuple[LiquidHandler, LabwireClient], ) -> None: _rig, client = served - await _call(client, "set_well_volume", {"well": "source_plate/A1", "volume_ul": 300.0}) - await _call(client, "pick_up_tips", {"tip_spots": ["tips/A1"]}) + await _call( + client, "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": 300.0} + ) + await _call(client, "pick_up_tips", {"tip_spots": ["labwire:deck/tips/A1"]}) result = await _call( client, "transfer", { - "source": "source_plate/A1", - "targets": ["target_plate/A1", "target_plate/B1"], + "source": "labwire:deck/source_plate/A1", + "targets": ["labwire:deck/target_plate/A1", "labwire:deck/target_plate/B1"], "volumes_ul": [50.0, 75.0], }, ) assert result["total_volume_ul"] == 125.0 - state = await _call(client, "describe_deck", {}) - volumes = {well["address"]: well["volume_ul"] for well in state["contents"]} - assert volumes["target_plate/A1"] == 50.0 - assert volumes["target_plate/B1"] == 75.0 + snapshot = await client.read_resource("labwire:deck") + volumes = {well["uri"]: well["volume_ul"] for well in snapshot.content["contents"]} + assert volumes["labwire:deck/target_plate/A1"] == 50.0 + assert volumes["labwire:deck/target_plate/B1"] == 75.0 async def test_eight_channels_aspirate_a_column_at_once( @@ -125,10 +149,12 @@ async def test_eight_channels_aspirate_a_column_at_once( ) -> None: """JSON arrays carry cardinality, which is why the range DSL is not exposed.""" _rig, client = served - column = [f"source_plate/{row}1" for row in "ABCDEFGH"] + column = [f"labwire:deck/source_plate/{row}1" for row in "ABCDEFGH"] for well in column: await _call(client, "set_well_volume", {"well": well, "volume_ul": 200.0}) - await _call(client, "pick_up_tips", {"tip_spots": [f"tips/{row}1" for row in "ABCDEFGH"]}) + await _call( + client, "pick_up_tips", {"tip_spots": [f"labwire:deck/tips/{row}1" for row in "ABCDEFGH"]} + ) result = await _call(client, "aspirate", {"wells": column, "volumes_ul": [50.0] * 8}) assert result["total_volume_ul"] == 400.0 @@ -138,10 +164,16 @@ async def test_telemetry_reports_cumulative_volume( ) -> None: _rig, client = served async with client.telemetry(["volume_dispensed_ul"]) as subscription: - await _call(client, "set_well_volume", {"well": "source_plate/A1", "volume_ul": 300.0}) - await _call(client, "pick_up_tips", {"tip_spots": ["tips/A1"]}) - await _call(client, "aspirate", {"wells": ["source_plate/A1"], "volumes_ul": [80.0]}) - await _call(client, "dispense", {"wells": ["target_plate/A1"], "volumes_ul": [80.0]}) + await _call( + client, "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": 300.0} + ) + await _call(client, "pick_up_tips", {"tip_spots": ["labwire:deck/tips/A1"]}) + await _call( + client, "aspirate", {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [80.0]} + ) + await _call( + client, "dispense", {"wells": ["labwire:deck/target_plate/A1"], "volumes_ul": [80.0]} + ) async with asyncio.timeout(20.0): async for sample in subscription: if sample.value == 80.0: @@ -156,35 +188,42 @@ async def test_moving_liquid_without_a_confirmation_is_refused( ) -> None: _rig, client = served with pytest.raises(ConfirmationRequiredError): - await client.submit("aspirate", {"wells": ["source_plate/A1"], "volumes_ul": [10.0]}) + await client.submit( + "aspirate", {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [10.0]} + ) async def test_reading_the_deck_needs_no_confirmation( served: tuple[LiquidHandler, LabwireClient], ) -> None: - """describe_deck is S0: an agent must always be able to see where it is.""" + """A resource read is not a command: no class, no confirmation, no run.""" _rig, client = served - handle = await client.submit("describe_deck", {}) - assert (await handle.result(timeout=20.0))["labware"] + snapshot = await client.read_resource("labwire:deck") + assert snapshot.content["labware"] + assert snapshot.index # and it enumerates the reference targets async def test_a_locked_plate_refuses_every_operation_touching_it(rig: LiquidHandler) -> None: """The one escalation v0.2 can enforce, since S2 and S3 gate identically.""" - annotations = AnnotationFile(resources={"source_plate": ResourceAnnotation(locked=True)}) + annotations = AnnotationFile( + resources={"labwire:deck/source_plate": ResourceAnnotation(locked=True)} + ) server = InstrumentServer(PyLabRobotInstrument(rig, annotations), confirmation_token=GRANT) client_end, server_end = MemoryTransport.pair() server.attach(server_end) async with LabwireClient.attach(client_end) as client: handle = await client.submit( "aspirate", - {"wells": ["source_plate/A1"], "volumes_ul": [10.0]}, + {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [10.0]}, confirmation=GRANT, ) with pytest.raises(InterlockError, match="locked"): await handle.result(timeout=20.0) # an unlocked plate is unaffected await client.submit( - "set_well_volume", {"well": "target_plate/A1", "volume_ul": 10.0}, confirmation=GRANT + "set_well_volume", + {"well": "labwire:deck/target_plate/A1", "volume_ul": 10.0}, + confirmation=GRANT, ) await server.aclose() @@ -216,9 +255,13 @@ async def test_aspirating_with_no_tip_is_an_interlock_not_a_crash( served: tuple[LiquidHandler, LabwireClient], ) -> None: _rig, client = served - await _call(client, "set_well_volume", {"well": "source_plate/A1", "volume_ul": 300.0}) + await _call( + client, "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": 300.0} + ) handle = await client.submit( - "aspirate", {"wells": ["source_plate/A1"], "volumes_ul": [10.0]}, confirmation=GRANT + "aspirate", + {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [10.0]}, + confirmation=GRANT, ) with pytest.raises(InterlockError, match="tip"): await handle.result(timeout=20.0) @@ -228,10 +271,14 @@ async def test_overdrawing_a_well_is_a_validation_error( served: tuple[LiquidHandler, LabwireClient], ) -> None: _rig, client = served - await _call(client, "set_well_volume", {"well": "source_plate/A1", "volume_ul": 50.0}) - await _call(client, "pick_up_tips", {"tip_spots": ["tips/A1"]}) + await _call( + client, "set_well_volume", {"well": "labwire:deck/source_plate/A1", "volume_ul": 50.0} + ) + await _call(client, "pick_up_tips", {"tip_spots": ["labwire:deck/tips/A1"]}) handle = await client.submit( - "aspirate", {"wells": ["source_plate/A1"], "volumes_ul": [500.0]}, confirmation=GRANT + "aspirate", + {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [500.0]}, + confirmation=GRANT, ) with pytest.raises(ValidationError, match="Not enough liquid"): await handle.result(timeout=20.0) @@ -240,23 +287,36 @@ async def test_overdrawing_a_well_is_a_validation_error( async def test_an_unknown_well_is_refused_before_anything_moves( served: tuple[LiquidHandler, LabwireClient], ) -> None: + """The server's reference walk refuses it; no run is ever created.""" + from labwire.core import UnknownReferenceError + _rig, client = served - handle = await client.submit( - "aspirate", {"wells": ["source_plate/Z99"], "volumes_ul": [10.0]}, confirmation=GRANT - ) - with pytest.raises(ValidationError, match="8 rows by 12 columns"): - await handle.result(timeout=20.0) + with pytest.raises(UnknownReferenceError) as caught: + await client.submit( + "aspirate", + {"wells": ["labwire:deck/source_plate/Z99"], "volumes_ul": [10.0]}, + confirmation=GRANT, + ) + details = caught.value.details + assert details is not None + assert details["reason"] == "no_such_item" + assert details["resolved_prefix"] == "labwire:deck/source_plate" + assert details["read"] == {"method": "resource/read", "params": {"uri": "labwire:deck"}} -async def test_a_malformed_address_is_rejected_by_the_schema( +async def test_a_malformed_reference_is_refused_as_unknown_reference( served: tuple[LiquidHandler, LabwireClient], ) -> None: - """The pattern catches shape without the server ever touching the deck.""" + """No pattern exists to catch shape; the reference walk refuses instead.""" + from labwire.core import UnknownReferenceError + _rig, client = served - with pytest.raises(ValidationError): + with pytest.raises(UnknownReferenceError) as caught: await client.submit( "aspirate", {"wells": ["not a valid address"], "volumes_ul": [10.0]}, confirmation=GRANT ) + assert caught.value.details is not None + assert caught.value.details["reason"] == "malformed_uri" async def test_mismatched_addresses_and_volumes_are_refused( @@ -265,7 +325,10 @@ async def test_mismatched_addresses_and_volumes_are_refused( _rig, client = served handle = await client.submit( "aspirate", - {"wells": ["source_plate/A1", "source_plate/B1"], "volumes_ul": [10.0]}, + { + "wells": ["labwire:deck/source_plate/A1", "labwire:deck/source_plate/B1"], + "volumes_ul": [10.0], + }, confirmation=GRANT, ) with pytest.raises(ValidationError, match="one to one"): @@ -278,7 +341,7 @@ async def test_declaring_more_volume_than_a_well_holds_is_refused( _rig, client = served handle = await client.submit( "set_well_volume", - {"well": "source_plate/A1", "volume_ul": 10_000.0}, + {"well": "labwire:deck/source_plate/A1", "volume_ul": 10_000.0}, confirmation=GRANT, ) with pytest.raises(ValidationError, match="overfill"): @@ -316,7 +379,7 @@ async def test_cancelling_a_finished_command_reports_it_cannot_be_cancelled( from labwire.core.errors import NotCancelableError _rig, client = served - handle = await client.submit("describe_deck", {}) + handle = await client.submit("stop", {}) await handle.result(timeout=20.0) with pytest.raises(NotCancelableError): await handle.cancel() @@ -342,11 +405,11 @@ async def test_a_run_produces_a_verifiable_signed_bundle(rig: LiquidHandler, tmp client_end, server_end = MemoryTransport.pair() server.attach(server_end) async with LabwireClient.attach(client_end) as client: - resolve(rig, "source_plate/A1").tracker.set_volume(300.0) - await _call(client, "pick_up_tips", {"tip_spots": ["tips/A1"]}) + resolve(rig, "labwire:deck/source_plate/A1").tracker.set_volume(300.0) + await _call(client, "pick_up_tips", {"tip_spots": ["labwire:deck/tips/A1"]}) handle = await client.submit( "aspirate", - {"wells": ["source_plate/A1"], "volumes_ul": [60.0]}, + {"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [60.0]}, confirmation=GRANT, ) await handle.result(timeout=20.0) diff --git a/packages/bridges/pylabrobot/tests/test_plr_cli.py b/packages/bridges/pylabrobot/tests/test_plr_cli.py index 8db8f06..feb7fd6 100644 --- a/packages/bridges/pylabrobot/tests/test_plr_cli.py +++ b/packages/bridges/pylabrobot/tests/test_plr_cli.py @@ -41,14 +41,15 @@ def test_check_reports_the_deck_and_every_safety_class() -> None: assert result.exit_code == 0, result.output assert "S2 aspirate" in result.output assert "S0 stop" in result.output - assert "source_plate: plate 8x12" in result.output - assert "tips: tip_rack 8x12 (96 tips)" in result.output + assert "labwire:deck/source_plate: plate 8x12" in result.output + assert "labwire:deck/tips: tip_rack 8x12 (96 tips)" in result.output def test_check_surfaces_hazards_and_locks(tmp_path: Path) -> None: path = tmp_path / "labwire-pylabrobot.yaml" path.write_text( - "version: 1\nresources:\n source_plate:\n hazard: corrosive\n locked: true\n" + "version: 1\nresources:\n labwire:deck/source_plate:\n" + " hazard: corrosive\n locked: true\n" ) result = runner.invoke(cli.app, ["check", TARGET, "-a", str(path)]) assert result.exit_code == 0, result.output @@ -66,7 +67,7 @@ def test_check_reports_an_excluded_command_as_excluded(tmp_path: Path) -> None: def test_a_bad_annotation_file_exits_nonzero(tmp_path: Path) -> None: path = tmp_path / "labwire-pylabrobot.yaml" - path.write_text("version: 1\nresources:\n plate: {hazzard: corrosive}\n") + path.write_text("version: 1\nresources:\n labwire:deck/plate: {hazzard: corrosive}\n") result = runner.invoke(cli.app, ["check", TARGET, "-a", str(path)]) assert result.exit_code == 1 diff --git a/packages/core/src/labwire/core/__init__.py b/packages/core/src/labwire/core/__init__.py index bbc8754..e9e1950 100644 --- a/packages/core/src/labwire/core/__init__.py +++ b/packages/core/src/labwire/core/__init__.py @@ -73,6 +73,7 @@ InstrumentResource, InstrumentServer, Interlock, + ResourceRef, ResourceSnapshot, RunRecord, SystemClock, @@ -144,6 +145,7 @@ "ResourceChangedData", "ResourceIndexEntry", "ResourceReadResult", + "ResourceRef", "ResourceRevision", "ResourceSnapshot", "ResourceSpec", diff --git a/packages/core/src/labwire/core/capabilities.py b/packages/core/src/labwire/core/capabilities.py index 9c244a1..e1078aa 100644 --- a/packages/core/src/labwire/core/capabilities.py +++ b/packages/core/src/labwire/core/capabilities.py @@ -214,6 +214,7 @@ def _walk( opaque: set[str], units: dict[str, str | None], claimed: list[tuple[str, str, dict[str, Any]]], + inherited_unit: str | None = None, ) -> None: """Record every path at which a number may appear, or which cannot be read.""" if node is False: @@ -233,7 +234,7 @@ def _walk( if _declares_number(resolved): numeric.add(path) - declared_unit = resolved.get("unit") + declared_unit = resolved.get("unit", inherited_unit) if path not in units or units[path] is None: units[path] = declared_unit if isinstance(declared_unit, str) else None for keyword in _CLAIMED_KEYWORDS: @@ -245,13 +246,37 @@ def _walk( opaque.add(path) # an unconstrained node permits a number return + own_unit = resolved.get("unit") + branch_unit = own_unit if isinstance(own_unit, str) else inherited_unit for key in _BRANCH_KEYS: member = resolved.get(key) if isinstance(member, list): for raw in cast("list[Any]", member): - _walk(raw, root, path, depth + 1, seen, numeric, opaque, units, claimed) + _walk( + raw, + root, + path, + depth + 1, + seen, + numeric, + opaque, + units, + claimed, + branch_unit, + ) elif member is not None: - _walk(member, root, path, depth + 1, seen, numeric, opaque, units, claimed) + _walk( + member, + root, + path, + depth + 1, + seen, + numeric, + opaque, + units, + claimed, + branch_unit, + ) for key in _NAMED_KEYS: members = _dict(resolved.get(key)) diff --git a/packages/core/src/labwire/core/server.py b/packages/core/src/labwire/core/server.py index d5f5627..2083d20 100644 --- a/packages/core/src/labwire/core/server.py +++ b/packages/core/src/labwire/core/server.py @@ -34,7 +34,7 @@ from collections.abc import Awaitable, Callable from datetime import UTC, datetime from pathlib import Path -from typing import Any, ClassVar, Concatenate, Protocol, cast +from typing import Annotated, Any, ClassVar, Concatenate, Protocol, cast from labwire.core._meta import PROTOCOL_VERSION, __version__ from labwire.core.capabilities import ( @@ -542,6 +542,33 @@ def resource( ) +def ResourceRef(kind: str, *, enumerated_by: str, description: str | None = None) -> Any: + """An ``Annotated[str, ...]`` parameter type carrying a typed reference. + + The ``resource_ref`` keyword and a generated description ride inside the + parameter's own schema (SPEC §7.2), which is the object that travels into + agent tool schemas, so the pointer to where valid values live reaches the + agent at the exact parameter it cannot fill. No pattern is emitted: + patterns are satisfiable by invention. + + Example: + >>> Container = ResourceRef("container", enumerated_by="labwire:deck") + >>> # async def transfer(self, ctx, source: Container, ...) -> ... + """ + article = "an" if kind[0] in "aeiou" else "a" + sentence = description or ( + f"Must be {article} {kind} listed in the index of resource {enumerated_by}; " + "read that resource for the valid values." + ) + return Annotated[ + str, + Field( + description=sentence, + json_schema_extra={"resource_ref": {"kind": kind, "enumerated_by": enumerated_by}}, + ), + ] + + def unit_field(unit_code: str, **kwargs: Any) -> Any: """A pydantic ``Field`` whose schema carries the SPEC §7.6 unit keyword. diff --git a/packages/drivers/src/labwire/drivers/syringe_pump.py b/packages/drivers/src/labwire/drivers/syringe_pump.py index ccccce6..8174dcb 100644 --- a/packages/drivers/src/labwire/drivers/syringe_pump.py +++ b/packages/drivers/src/labwire/drivers/syringe_pump.py @@ -19,16 +19,37 @@ Instrument, InstrumentServer, InterlockError, + ResourceSnapshot, channel, command, interlock, + resource, + unit_field, ) from labwire.drivers._lineproto import LineProtocolClient -from pydantic import ConfigDict +from pydantic import BaseModel, ConfigDict _POLL_S = 0.02 +class SyringeInfo(BaseModel): + """The installed syringe: the pump's one piece of tree-shaped state. + + Deliberately present on an instrument with **no references at all**: the + resource primitive is not deck-shaped, and this exercises content typing, + the derived revision, and change notification without a single + resource_ref anywhere. + """ + + model_config = ConfigDict(extra="forbid") + + model: str + capacity_ul: float = unit_field("uL") + barrel_diameter_mm: float = unit_field("mm") + installed_ul: float = unit_field("uL") + """How much the syringe currently holds, by the pump's own accounting.""" + + class DispenseResult(TypedDict): """How much liquid was actually dispensed.""" @@ -68,10 +89,37 @@ class SyringePump(Instrument): description="Line occlusion stalled the motor. Cleared by clear_occlusion.", kind="soft", ) + syringe = resource( + "labwire:syringe", + kind="consumable", + title="Installed syringe", + description=( + "The syringe currently installed in the pump: its model, capacity, and " + "how much it holds by the pump's own accounting. Changes when the " + "plunger moves." + ), + content_model=SyringeInfo, + item_kinds=[], + ) def __init__(self, host: str, port: int) -> None: super().__init__() self._link = LineProtocolClient(host, port) + self._dispensed_total = 0.0 + + @syringe.reader + def _read_syringe(self) -> ResourceSnapshot: + # The simulated pump models a 5 mL syringe; the capacity and barrel + # figures describe that simulated hardware, not any vendor's. + return ResourceSnapshot( + index=[], + content=SyringeInfo( + model="SimSyringe-5000", + capacity_ul=5000.0, + barrel_diameter_mm=12.45, + installed_ul=max(0.0, 5000.0 - self._dispensed_total), + ), + ) async def on_start(self, server: InstrumentServer) -> None: """Open the pump connection and verify it answers. @@ -126,6 +174,8 @@ async def dispense( raise InterlockError("occlusion detected: motor stalled") if state == "IDLE": await ctx.progress(1.0, "dispense complete") + self._dispensed_total += dispensed + self.syringe.touch() return {"dispensed_ul": dispensed} if ctx.cancel_requested: await self._cmd("STP") From 24c97b012cbfb0b99c657c3c6238871c239732c5 Mon Sep 17 00:00:00 2001 From: Silous Ramelli <204268110+TheRoboMaster123@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:35:56 -0700 Subject: [PATCH 4/5] feat(pylabrobot)!: gripper moves at S3; MCP surfaces resources and grants The condition LIMITATIONS documented is met: real S3 exists, so the gripper ships. move_plate, move_lid, and move_resource are exposed at S3, non-interruptible because a plate held mid-traverse has no safe interruption, with resource-typed parameters (a plate is labware, a destination is a site, and passing a well where a site is wanted is a kind mismatch the reference walk catches before authorization). Verified against the chatterbox backend: the plate's parent really becomes the staging site. The rig gains a five-site staging carrier, indexed as kind site under PyLabRobot's own holder names. The demo shows the ceremony beat by beat, and CI asserts the order: the standing S2 confirmation that moved 800 uL of liquid this session is refused for one plate move; the refusal records a pending request; the operator (played by the harness on the same machine, with the one-user-one-machine caveat printed) lists and approves it; the granted move runs; and then the same valid, unexpired grant is refused on different parameters, the one beat that proves the binding is to parameters rather than an S3-shaped password. The manifest records mode grant, use 1/1, identity_verified false, and CI asserts no bundle anywhere contains a grant id. In the live agent path the harness plays operator only after the agent itself reports the refusal. The agent prompt is now hint-free and CI enforces it: no labwire:, no deck, no describe, no resource, no read, no URI-spelled labware name. Discovery has to come from the descriptor, and hygiene tests keep the preconditions true: no reference parameter carries a pattern, every reference points at a declared resource, and the descriptor leaks no user-styled labware name and spells no path. Writing those tests caught two real leaks: a docstring example carrying the old address grammar into returns_schema, and the interim prompt hint from V3. The MCP adapter maps resources to MCP resources under a namespaced URI used only where MCP requires global uniqueness, keeps wire spellings everywhere the model acts, synthesizes a read tool whose uri parameter is an enum, branches S2 confirmation from S3 authorization and never emits both, marks the upper classes with ToolAnnotations while leaving readOnlyHint unset because Labwire cannot yet tell a read from a state edit, and serializes error details instead of flattening them, because request_id, did_you_mean, and the ready-to-send read are the recovery paths the protocol designed. Co-Authored-By: Claude --- .github/workflows/ci.yml | 2 +- examples/liquid_handling/claude_dilution.py | 120 +++++++-- examples/liquid_handling/dilution.py | 21 +- examples/liquid_handling/rig.py | 100 ++++++- .../src/labwire/bridges/pylabrobot/bridge.py | 75 +++++- .../labwire/bridges/pylabrobot/introspect.py | 28 ++ packages/bridges/pylabrobot/tests/conftest.py | 2 + .../tests/test_deck_introspection.py | 17 +- .../pylabrobot/tests/test_deck_state.py | 4 +- .../tests/test_liquid_handler_bridge.py | 250 +++++++++++++++++- packages/mcp/src/labwire/mcp/server.py | 180 +++++++++++-- tests/test_demo.py | 22 +- 12 files changed, 757 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d6e810..49a3f94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,6 @@ jobs: uv run --no-sync python -c " from labwire.bridges.pylabrobot import command_surface, split_deck_uri assert split_deck_uri('labwire:deck/plate/A1') == ('plate', 'A1') - assert len(command_surface()) == 9 + assert len(command_surface()) == 12 print('pure layers import without pylabrobot') " diff --git a/examples/liquid_handling/claude_dilution.py b/examples/liquid_handling/claude_dilution.py index 4cc3524..baecf7a 100644 --- a/examples/liquid_handling/claude_dilution.py +++ b/examples/liquid_handling/claude_dilution.py @@ -32,6 +32,7 @@ DilutionRig, demo_steps, dilution_wells, + gripper_act, ) MAX_ROUNDS = 60 @@ -39,28 +40,28 @@ SYSTEM_PROMPT = f"""You are operating a liquid handler over the Labwire protocol. The \ machine is driven by PyLabRobot, exposed to you as tools. -Read the labwire:deck resource before planning: its index lists every container and \ -tip site a command parameter can reference, as full URIs. Use those URIs exactly as \ -the index spells them. - Goal: run a two-fold serial dilution across row A of the dilution plate, \ -{{steps}} steps. Each step moves {STEP_VOLUME_UL:.0f} uL from the previous well into the \ -next, so step 1 goes from the dye stock well A1 of the source plate into the first \ -dilution well, step 2 from that well into the second, and so on. +{{steps}} steps of {STEP_VOLUME_UL:.0f} uL each, starting from the dye stock in well A1 \ +of the source plate and carrying forward one well at a time. Technique: use a fresh tip for each step. Pick up one tip, transfer, then discard it \ before the next step. Carrying one tip through the series would contaminate it. -Safety: every tool tells you its safety class. Everything that moves or consumes \ -material is S2 and requires confirmation="{STANDING_GRANT}", the standing grant this \ -session's operator issued. describe_deck is S0 and set_well_volume is S1; neither needs \ -a confirmation. Never invent a confirmation value. +When the series is complete, move the dilution plate to an empty staging site with \ +move_plate. + +Safety: every tool tells you its safety class. Liquid handling is S2 and requires \ +confirmation="{STANDING_GRANT}", the standing grant this session's operator issued. \ +S3 tools take an operator grant in the authorization field instead; a confirmation \ +never satisfies them, and you can never invent either value. If an S3 call is refused, \ +report the refusal and wait; if your operator gives you a grant id, present it for \ +exactly the same call. -Units are UCUM codes given per parameter; volumes are in uL. If a call fails, read the \ -error: it names what would have worked. +Units are UCUM codes given per parameter; volumes are in uL. If a call fails, the \ +error names what would have worked. -When the series is complete, stop calling tools and reply with one line: \ -FINAL: {{{{"steps_completed": }}}}""" +When the series is done and the plate is moved, stop calling tools and reply with one \ +line: FINAL: {{{{"steps_completed": }}}}""" async def build_tools( @@ -100,7 +101,7 @@ async def build_tools( if spec.unit_annotations: units = ", ".join(f"{k} in {v}" for k, v in spec.unit_annotations.items()) notes.append(f"Units (UCUM): {units}.") - if spec.safety_class in ("S2", "S3"): + if spec.safety_class == "S2": properties = dict(schema.get("properties", {})) properties["confirmation"] = { "type": "string", @@ -108,6 +109,24 @@ async def build_tools( } schema["properties"] = properties notes.append("Requires a confirmation value.") + elif spec.safety_class == "S3": + properties = dict(schema.get("properties", {})) + properties["authorization"] = { + "type": "object", + "additionalProperties": False, + "required": ["grant_id"], + "description": ( + "Operator grant. You cannot mint this. Present only an id an " + "operator gave you for this exact call." + ), + "properties": {"grant_id": {"type": "string"}}, + } + schema["properties"] = properties + notes.append( + "HAZARDOUS: requires an operator grant bound to these exact parameter " + "values; a confirmation string will not authorize it. If you hold no " + "grant, call once WITHOUT authorization and report the refusal." + ) tools.append( { "name": spec.name, @@ -138,20 +157,68 @@ async def execute_tool( spec = registry[name] payload = dict(arguments) confirmation = payload.pop("confirmation", None) + authorization = payload.pop("authorization", None) + grant_id = authorization.get("grant_id") if isinstance(authorization, dict) else None try: handle = await client.submit( spec.name, payload, confirmation=str(confirmation) if confirmation is not None else None, + authorization=str(grant_id) if grant_id is not None else None, ) result = await handle.result(timeout=120.0) except (LabwireError, TimeoutError) as exc: + details = getattr(exc, "details", None) + if details: + # Flattening would destroy request_id, did_you_mean, and the + # ready-to-send read; the recovery path lives in these fields. + return json.dumps( + {"error": str(exc), "category": getattr(exc, "category", None), "details": details} + ) return f"ERROR: {exc}" if spec.name in {"transfer", "dispense"}: transfers.append(handle.command_id) return json.dumps(result) +def _pending_request_id(results: list[dict[str, Any]]) -> str | None: + """The request id of an authorization_required refusal, if one just happened.""" + for entry in results: + content = entry.get("content") + if not isinstance(content, str) or "authorization_required" not in content: + continue + try: + details = json.loads(content).get("details", {}) + except ValueError: + continue + if details.get("reason") == "absent" and details.get("request_id"): + return str(details["request_id"]) + return None + + +def _operator_approves(rig: DilutionRig, request_id: str) -> str: + """The operator role: approve one pending request from the server's store. + + NOTE: demo and operator run as one user on one machine here; nothing in + this process enforces the separation. On a real bench the store lives + where the agent cannot write it. + """ + from datetime import UTC, datetime, timedelta + + from labwire.core import GrantStore + + store = GrantStore(rig.grant_dir, serial_number="lh_deck") + grant = store.approve( + request_id, + now=datetime.now(UTC), + ttl=timedelta(minutes=15), + max_uses=1, + issued_by="operator", + ) + print(f" [operator] approved {request_id} -> grant {grant.grant_id[:10]}... (1 use)") + return grant.grant_id + + async def prepare(rig: DilutionRig, steps: int) -> None: """Declare what the plates hold, as a human loading the deck would.""" await rig.call( @@ -179,6 +246,7 @@ async def run_claude(rig: DilutionRig, api_key: str, steps: int, transfers: list messages: list[dict[str, Any]] = [ {"role": "user", "content": "The deck is loaded. Run the dilution series."} ] + operator_acted = False for _ in range(MAX_ROUNDS): response = await anthropic.messages.create( model=model, @@ -199,6 +267,25 @@ async def run_claude(rig: DilutionRig, api_key: str, steps: int, transfers: list results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": + # If the agent stopped because an S3 call was refused, the OPERATOR + # (this harness, playing that role on the same machine) approves + # the pending request and hands the agent a single-use grant id. + request_id = _pending_request_id(results) + if request_id and not operator_acted: + operator_acted = True + grant_id = _operator_approves(rig, request_id) + messages.append( + { + "role": "user", + "content": ( + f"Operator here. I reviewed request {request_id} on the " + f"instrument host and approved it: grant id {grant_id}, " + "single use, expires in 15 minutes. Proceed with exactly " + "the call you reported." + ), + } + ) + continue break messages.append({"role": "user", "content": results}) @@ -231,6 +318,7 @@ async def main() -> None: else: print("ANTHROPIC_API_KEY not set - falling back to the scripted dilution\n") await run_scripted(rig, steps, transfers) + transfers.append(await gripper_act(rig)) snapshot = await rig.client.read_resource("labwire:deck") print("\nfinal deck contents:") diff --git a/examples/liquid_handling/dilution.py b/examples/liquid_handling/dilution.py index afe1e96..d5cd837 100644 --- a/examples/liquid_handling/dilution.py +++ b/examples/liquid_handling/dilution.py @@ -31,6 +31,7 @@ DilutionRig, demo_steps, dilution_wells, + gripper_act, ) @@ -120,11 +121,29 @@ async def main() -> None: mounted = sum(1 for channel in state["channels"] if channel["has_tip"]) print(f"\n channels holding a tip: {mounted} (every tip was discarded)") - bundle = rig.bundle_for(transfer_runs[-1]) + move_run = await gripper_act(rig) + + bundle = rig.bundle_for(move_run) print(f"\nsigned evidence: {bundle}") result = verify_bundle(bundle) status = "OK - authentic" if result.ok else f"FAILED: {'; '.join(result.errors)}" print(f" labwire verify: {status}") + import json as _json + + manifest = _json.loads((bundle / "manifest.json").read_text()) + auth = manifest.get("authorization", {}) + print( + f" command {manifest['command']['name']} " + f"safety_class {manifest['command']['safety_class']}" + ) + print( + f" authorization mode={auth.get('mode')} use {auth.get('use_index')}/1 " + f'issued_by "{auth.get("issued_by")}" [unauthenticated note]' + ) + print( + f" identity_verified {auth.get('identity_verified')} " + "<- deployment policy and parameter binding proven; NOT who" + ) if not result.ok: raise SystemExit(1) diff --git a/examples/liquid_handling/rig.py b/examples/liquid_handling/rig.py index 4cd132e..6c80ae3 100644 --- a/examples/liquid_handling/rig.py +++ b/examples/liquid_handling/rig.py @@ -18,7 +18,7 @@ from labwire.core import InstrumentServer, LabwireClient from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend -from pylabrobot.resources import Cor_96_wellplate_360ul_Fb +from pylabrobot.resources import PLT_CAR_L5AC_A00, Cor_96_wellplate_360ul_Fb from pylabrobot.resources.hamilton import STARLetDeck, hamilton_96_tiprack_1000uL_filter ANNOTATIONS = Path(__file__).parent / "labwire-pylabrobot.yaml" @@ -54,6 +54,9 @@ async def build_liquid_handler() -> LiquidHandler: deck.assign_child_resource(hamilton_96_tiprack_1000uL_filter(name="tips"), rails=1) deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="source_plate"), rails=7) deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="dilution_plate"), rails=13) + # Five empty plate sites, staging_0 through staging_4: the gripper's + # legal destinations, indexed as kind "site". + deck.assign_child_resource(PLT_CAR_L5AC_A00(name="staging"), rails=19) return handler @@ -68,6 +71,7 @@ class DilutionRig: def __init__(self) -> None: self.client: LabwireClient self.manifest_dir: Path + self.grant_dir: Path self._stack: AsyncExitStack @classmethod @@ -80,8 +84,17 @@ async def start(cls, manifest_dir: Path) -> Self: handler = await build_liquid_handler() instrument = PyLabRobotInstrument(handler, load_annotations(ANNOTATIONS)) + # The bridge declares S3 gripper commands, so a grant store is + # mandatory: a server with S3 commands and no store refuses to start. + # NOTE: this demo runs operator and agent as one user on one machine; + # nothing here enforces the separation. On a real bench the store + # lives where the agent cannot write it. + rig.grant_dir = manifest_dir / "grants" server = InstrumentServer( - instrument, confirmation_token=STANDING_GRANT, manifest_dir=manifest_dir + instrument, + confirmation_token=STANDING_GRANT, + manifest_dir=manifest_dir, + grant_store=rig.grant_dir, ) stack.push_async_callback(server.aclose) ws_server = await stack.enter_async_context(server.serve_websocket("127.0.0.1", 0)) @@ -110,6 +123,89 @@ def bundle_for(self, run_id: str) -> Path: return self.manifest_dir / run_id +async def gripper_act(rig: "DilutionRig") -> str: + """The S3 ceremony, beat by beat, shared by both demos. + + Four things v0.2 could not make visible: the standing S2 grant that moved + the whole dilution series does not move one plate; the refusal is + productive (it creates the request a human approves); the approved grant + moves the plate; and the same valid grant is refused on different + parameters, which is the beat proving the binding is to parameters rather + than an S3-shaped password. + + Returns the run id of the granted move, for signed-evidence verification. + """ + from datetime import timedelta + + from labwire.core import AuthorizationRequiredError, GrantStore + + params = {"plate": "labwire:deck/dilution_plate", "to": "labwire:deck/staging-0"} + + print("\nmoving the dilution plate to the staging site (S3: gripper move)") + try: + await rig.call("move_plate", params) + raise AssertionError("an S3 command ran on a confirmation; that is the F4 bug") + except AuthorizationRequiredError as refused: + details = refused.details or {} + print( + f" REFUSED -32011 reason={details.get('reason')} " + f"mintable_by_agent={details.get('mintable_by_agent')}" + ) + print( + f" the standing confirmation moved {8 * 100} uL of liquid this session; " + "it does not move one plate" + ) + request_id = str(details.get("request_id")) + print(f" operator instruction: {details.get('operator_instruction')}") + + # OPERATOR, separate role. This demo runs both as one user on one machine; + # nothing here enforces the separation. On a real bench the grant store + # lives where the agent cannot write it. + print("\n --- operator, on the instrument host ---") + print(" $ labwire grant list") + store = GrantStore(rig.grant_dir, serial_number="lh_deck") + pending = store.find_pending( + request_id, now=__import__("datetime").datetime.now(__import__("datetime").UTC) + ) + assert pending is not None + print( + f" {pending.request_id} {pending.command} S3 digest {pending.params_digest[:23]}..." + ) + for name, value in sorted(pending.params.items()): + print(f" {name:6} {value}") + print(f" $ labwire grant approve {request_id} --ttl 15m --uses 1") + grant = store.approve( + request_id, + now=__import__("datetime").datetime.now(__import__("datetime").UTC), + ttl=timedelta(minutes=15), + max_uses=1, + issued_by="operator", + note="plate to staging", + ) + print(f" grant {grant.grant_id[:10]}... uses 0/1 expires {grant.expires_at}") + print(" --- end operator ---\n") + + handle = await rig.client.submit("move_plate", params, authorization=grant.grant_id) + moved = await handle.result(timeout=120.0) + print(f" GRANTED {moved['moved']} -> {moved['to']} (use 1/1)") + + # the beat that proves the binding: same grant, different plate + try: + await rig.client.submit( + "move_plate", + {"plate": "labwire:deck/source_plate", "to": "labwire:deck/staging-1"}, + authorization=grant.grant_id, + ) + raise AssertionError("a spent, differently-bound grant was accepted") + except AuthorizationRequiredError as mismatched: + reason = (mismatched.details or {}).get("reason") + print( + f" REFUSED -32011 reason={reason} " + "(a valid grant for the other plate does not move this one)" + ) + return handle.command_id + + def dilution_wells(steps: int) -> list[str]: """Addresses of the dilution series, across row A of the dilution plate. diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py index 0106cbc..f61187f 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/bridge.py @@ -52,6 +52,9 @@ Container = ResourceRef("container", enumerated_by=DECK_URI) TipSite = ResourceRef("tip_site", enumerated_by=DECK_URI) +Labware = ResourceRef("labware", enumerated_by=DECK_URI) +Site = ResourceRef("site", enumerated_by=DECK_URI) +Lid = ResourceRef("lid", enumerated_by=DECK_URI) """Typed reference parameter types (SPEC §7.2). Until v0.3 the bridge published an invented address pattern here, which was @@ -113,7 +116,7 @@ class TipResult(BaseModel): of a protocol. See SPEC-FINDINGS.md, finding F5. Example: - >>> TipResult(tip_spots=["tips/A1"], channels_used=[0]).channels_used + >>> TipResult(channels_used=[0]).channels_used [0] """ @@ -129,7 +132,7 @@ class LiquidResult(BaseModel): """What an aspirate or dispense moved. Example: - >>> LiquidResult(wells=["plate/A1"], total_volume_ul=50.0).total_volume_ul + >>> LiquidResult(wells=["w"], total_volume_ul=50.0).total_volume_ul 50.0 """ @@ -143,8 +146,8 @@ class TransferResult(BaseModel): """What a transfer moved, and where. Example: - >>> TransferResult(source="a/A1", targets=["b/A1"], total_volume_ul=10.0).source - 'a/A1' + >>> TransferResult(source="s", targets=["t"], total_volume_ul=10.0).total_volume_ul + 10.0 """ model_config = ConfigDict(extra="forbid") @@ -158,7 +161,7 @@ class WellVolumeResult(BaseModel): """The volume a well is now recorded as holding. Example: - >>> WellVolumeResult(well="plate/A1", volume_ul=200.0).volume_ul + >>> WellVolumeResult(well="w", volume_ul=200.0).volume_ul 200.0 """ @@ -168,6 +171,21 @@ class WellVolumeResult(BaseModel): volume_ul: float +class MoveResult(BaseModel): + """What a gripper move did: the thing, where it was, where it is now. + + Example: + >>> MoveResult(moved="labwire:deck/p", origin="labwire:deck/a", to="labwire:deck/b").to + 'labwire:deck/b' + """ + + model_config = ConfigDict(extra="forbid") + + moved: str + origin: str + to: str + + class StopResult(BaseModel): """Confirmation that the handler was stopped. @@ -213,6 +231,7 @@ class PyLabRobotBridge(Instrument): "tip_rack", "trough", "trash", + "lid", "container", "tip_site", "site", @@ -440,6 +459,46 @@ async def do_set_well_volume( raise map_error(exc) from exc return WellVolumeResult(well=well, volume_ul=volume_ul) + async def _move_gripped( + self, ctx: CommandContext, moved_uri: str, to_uri: str, op: str + ) -> MoveResult: + thing = resolve(self._lh, moved_uri) + destination = resolve(self._lh, to_uri) + self._refuse_locked([thing, destination]) + origin_name = getattr(getattr(thing, "parent", None), "name", None) + origin = f"{DECK_URI}/{origin_name}" if origin_name else DECK_URI + operation = getattr(self._lh, op) + await self._operate(ctx, operation(thing, destination), op) + self._publish_state() + return MoveResult(moved=moved_uri, origin=origin, to=to_uri) + + async def do_move_plate( + self, + ctx: CommandContext, + plate: Labware, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + to: Site, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + ) -> MoveResult: + """Grip a plate and set it down on another site.""" + return await self._move_gripped(ctx, plate, to, "move_plate") + + async def do_move_lid( + self, + ctx: CommandContext, + lid: Lid, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + to: Labware, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + ) -> MoveResult: + """Grip a plate lid and move it onto another plate.""" + return await self._move_gripped(ctx, lid, to, "move_lid") + + async def do_move_resource( + self, + ctx: CommandContext, + moved: Labware, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + to: Site, # pyright: ignore[reportInvalidTypeForm, reportUnknownParameterType, reportGeneralTypeIssues] + ) -> MoveResult: + """Grip any labware and move it to a site.""" + return await self._move_gripped(ctx, moved, to, "move_resource") + async def do_stop(self, ctx: CommandContext) -> StopResult: """Stop the liquid handler.""" try: @@ -458,6 +517,9 @@ async def do_stop(self, ctx: CommandContext) -> StopResult: "dispense": "do_dispense", "transfer": "do_transfer", "set_well_volume": "do_set_well_volume", + "move_plate": "do_move_plate", + "move_lid": "do_move_lid", + "move_resource": "do_move_resource", "stop": "do_stop", } @@ -560,6 +622,9 @@ def PyLabRobotInstrument( ), description=(override.description if override else None) or spec.description, estimated_duration_s=(override.estimated_duration_s if override else None), + # A plate held in the gripper mid-traverse has no safe interruption; + # command/cancel on these returns -32007 instead (SPEC 8.3). + interruptible=spec.name not in {"move_plate", "move_lid", "move_resource"}, )(implementation) generated = type("PyLabRobot_LiquidHandler", (PyLabRobotBridge,), namespace) diff --git a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py index d3218f8..a7af66c 100644 --- a/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py +++ b/packages/bridges/pylabrobot/src/labwire/bridges/pylabrobot/introspect.py @@ -36,6 +36,7 @@ "plate_carrier": "carrier", "tip_carrier": "carrier", "plate_holder": "site", + "lid": "lid", "deck": "deck", } @@ -47,6 +48,7 @@ "trough": ["trough", "container", "labware"], "trash": ["trash", "labware"], "site": ["site"], + "lid": ["lid", "labware"], "carrier": ["labware"], } """Registered kind arrays per bridge classification; OTHER maps to none and @@ -62,6 +64,7 @@ class LabwareKind(enum.StrEnum): TRASH = "trash" CARRIER = "carrier" SITE = "site" + LID = "lid" DECK = "deck" OTHER = "other" """Recognized as present, but of unknown purpose and not referenceable.""" @@ -382,6 +385,31 @@ def command_surface() -> list[DraftCommand]: ), safety_class="S1", ), + DraftCommand( + name="move_plate", + description=( + "Grip a plate, lift it off its site, carry it across the deck, and set " + "it down on another site. The arm travels over whatever is in between; " + "a wrong destination is a collision, not a bad pipetting step." + ), + safety_class="S3", + ), + DraftCommand( + name="move_lid", + description=( + "Grip a plate lid and move it onto another plate or site. The arm " + "travels over whatever is in between." + ), + safety_class="S3", + ), + DraftCommand( + name="move_resource", + description=( + "Grip any labware and move it to a site. The most general gripper " + "operation, and exactly as capable of a collision as the others." + ), + safety_class="S3", + ), DraftCommand( name="stop", description="Stop the liquid handler immediately.", diff --git a/packages/bridges/pylabrobot/tests/conftest.py b/packages/bridges/pylabrobot/tests/conftest.py index 90107fb..ff2dbcd 100644 --- a/packages/bridges/pylabrobot/tests/conftest.py +++ b/packages/bridges/pylabrobot/tests/conftest.py @@ -14,6 +14,7 @@ from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend from pylabrobot.resources import ( + PLT_CAR_L5AC_A00, Cor_96_wellplate_360ul_Fb, set_tip_tracking, set_volume_tracking, @@ -49,6 +50,7 @@ async def rig() -> AsyncIterator[LiquidHandler]: deck.assign_child_resource(hamilton_96_tiprack_1000uL_filter(name="tips"), rails=1) deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="source_plate"), rails=7) deck.assign_child_resource(Cor_96_wellplate_360ul_Fb(name="target_plate"), rails=13) + deck.assign_child_resource(PLT_CAR_L5AC_A00(name="staging"), rails=19) yield handler with contextlib.suppress(RuntimeError): await handler.stop() # a test may have stopped it through the protocol diff --git a/packages/bridges/pylabrobot/tests/test_deck_introspection.py b/packages/bridges/pylabrobot/tests/test_deck_introspection.py index 75b0d13..a917655 100644 --- a/packages/bridges/pylabrobot/tests/test_deck_introspection.py +++ b/packages/bridges/pylabrobot/tests/test_deck_introspection.py @@ -163,9 +163,9 @@ def test_declaring_a_wells_contents_is_s1_because_it_moves_nothing() -> None: assert classes["set_well_volume"] == "S1" -def test_the_command_surface_has_nine_operations() -> None: - """Ten in v0.2; describe_deck became the deck resource.""" - assert len(command_surface()) == 9 +def test_the_command_surface_has_twelve_operations() -> None: + """describe_deck became the deck resource; the three gripper moves joined.""" + assert len(command_surface()) == 12 def test_the_untyped_backend_passthrough_is_not_exposed() -> None: @@ -174,9 +174,12 @@ def test_the_untyped_backend_passthrough_is_not_exposed() -> None: assert not any("backend" in name or "kwargs" in name for name in names) -def test_gripper_moves_are_not_exposed_in_this_version() -> None: - names = {c.name for c in command_surface()} - assert not (names & {"move_plate", "move_lid", "move_resource"}) +def test_gripper_moves_are_exposed_and_hazardous() -> None: + """The condition LIMITATIONS documented is met: real S3 exists (SPEC 8.6).""" + classes = {c.name: c.safety_class for c in command_surface()} + assert classes["move_plate"] == "S3" + assert classes["move_lid"] == "S3" + assert classes["move_resource"] == "S3" def test_every_command_has_a_description() -> None: @@ -187,6 +190,6 @@ async def test_the_command_surface_does_not_depend_on_the_deck(rig: LiquidHandle """PyLabRobot's frontend is one class, so there is nothing to discover.""" before = introspect(rig).commands rig.deck.assign_child_resource( - Cor_96_wellplate_360ul_Fb(name="extra"), location=Coordinate(600, 200, 100) + Cor_96_wellplate_360ul_Fb(name="extra"), location=Coordinate(700, 300, 100) ) assert introspect(rig).commands == before diff --git a/packages/bridges/pylabrobot/tests/test_deck_state.py b/packages/bridges/pylabrobot/tests/test_deck_state.py index 9f4ac56..c9d18d3 100644 --- a/packages/bridges/pylabrobot/tests/test_deck_state.py +++ b/packages/bridges/pylabrobot/tests/test_deck_state.py @@ -170,9 +170,9 @@ async def test_an_annotation_naming_a_resource_that_is_not_there_is_refused( def test_an_annotation_naming_an_unexposed_command_is_refused() -> None: from labwire.bridges.pylabrobot.annotations import CommandAnnotation - with pytest.raises(AnnotationError, match="move_plate"): + with pytest.raises(AnnotationError, match="describe_deck"): check( - AnnotationFile(commands={"move_plate": CommandAnnotation(exclude=True)}), + AnnotationFile(commands={"describe_deck": CommandAnnotation(exclude=True)}), known_resources=set(), known_labware=set(), known_commands={c.name for c in command_surface()}, diff --git a/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py b/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py index d6de6ae..3988a3e 100644 --- a/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py +++ b/packages/bridges/pylabrobot/tests/test_liquid_handler_bridge.py @@ -1,6 +1,7 @@ import asyncio import json from collections.abc import AsyncIterator +from pathlib import Path from typing import Any import pytest @@ -20,9 +21,15 @@ @pytest.fixture -async def served(rig: LiquidHandler) -> AsyncIterator[tuple[LiquidHandler, LabwireClient]]: +async def served( + rig: LiquidHandler, tmp_path: Path +) -> AsyncIterator[tuple[LiquidHandler, LabwireClient]]: """The rig served over the protocol, with an operator grant configured.""" - server = InstrumentServer(PyLabRobotInstrument(rig), confirmation_token=GRANT) + server = InstrumentServer( + PyLabRobotInstrument(rig), + confirmation_token=GRANT, + grant_store=tmp_path / "grants", + ) client_end, server_end = MemoryTransport.pair() server.attach(server_end) async with LabwireClient.attach(client_end) as client: @@ -208,7 +215,13 @@ async def test_a_locked_plate_refuses_every_operation_touching_it(rig: LiquidHan annotations = AnnotationFile( resources={"labwire:deck/source_plate": ResourceAnnotation(locked=True)} ) - server = InstrumentServer(PyLabRobotInstrument(rig, annotations), confirmation_token=GRANT) + import tempfile + + server = InstrumentServer( + PyLabRobotInstrument(rig, annotations), + confirmation_token=GRANT, + grant_store=Path(tempfile.mkdtemp(prefix="labwire-grants-")), + ) client_end, server_end = MemoryTransport.pair() server.attach(server_end) async with LabwireClient.attach(client_end) as client: @@ -400,7 +413,10 @@ async def test_a_run_produces_a_verifiable_signed_bundle(rig: LiquidHandler, tmp from labwire.core.signing import verify_bundle server = InstrumentServer( - PyLabRobotInstrument(rig), confirmation_token=GRANT, manifest_dir=tmp_path + PyLabRobotInstrument(rig), + confirmation_token=GRANT, + manifest_dir=tmp_path, + grant_store=tmp_path / "grants", ) client_end, server_end = MemoryTransport.pair() server.attach(server_end) @@ -422,3 +438,229 @@ async def test_a_run_produces_a_verifiable_signed_bundle(rig: LiquidHandler, tmp assert manifest["command"]["name"] == "aspirate" assert manifest["command"]["safety_class"] == "S2" # recorded, not just enforced assert manifest["command"]["params"]["volumes_ul"] == [60.0] + + +# --- the gripper: S3, and the ceremony that makes it different -------------- + + +async def test_the_standing_s2_grant_does_not_move_a_plate( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + """The confirmation that moved 800 uL of liquid does not move one plate.""" + from labwire.core import AuthorizationRequiredError + + _rig, client = served + with pytest.raises(AuthorizationRequiredError) as caught: + await client.submit( + "move_plate", + {"plate": "labwire:deck/target_plate", "to": "labwire:deck/staging-0"}, + confirmation=GRANT, + ) + details = caught.value.details + assert details is not None + assert details["reason"] == "absent" + assert details["mintable_by_agent"] is False + assert details["request_id"].startswith("req-") + + +async def test_an_approved_grant_moves_the_plate_and_binds_to_it( + rig: LiquidHandler, tmp_path: Path +) -> None: + """Refusal, approval, success, and then the beat that proves the binding: + the same valid grant refused on different parameters.""" + from datetime import timedelta + + from labwire.core import AuthorizationRequiredError + + server = InstrumentServer( + PyLabRobotInstrument(rig), confirmation_token=GRANT, grant_store=tmp_path / "g" + ) + client_end, server_end = MemoryTransport.pair() + server.attach(server_end) + async with LabwireClient.attach(client_end) as client: + params = {"plate": "labwire:deck/target_plate", "to": "labwire:deck/staging-0"} + with pytest.raises(AuthorizationRequiredError) as refused: + await client.submit("move_plate", params) + assert refused.value.details is not None + request_id = refused.value.details["request_id"] + + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + grant = store.approve( + request_id, now=server.clock.now(), ttl=timedelta(minutes=15), max_uses=2 + ) + + handle = await client.submit("move_plate", params, authorization=grant.grant_id) + moved = await handle.result(timeout=20.0) + assert moved["to"] == "labwire:deck/staging-0" + assert moved["origin"] == "labwire:deck/deck" + + # a valid, unexpired, correct-command grant on OTHER parameters + with pytest.raises(AuthorizationRequiredError) as mismatched: + await client.submit( + "move_plate", + {"plate": "labwire:deck/source_plate", "to": "labwire:deck/staging-1"}, + authorization=grant.grant_id, + ) + assert mismatched.value.details is not None + assert mismatched.value.details["reason"] == "params_mismatch" + await server.aclose() + + +async def test_a_gripper_move_to_a_container_is_a_kind_mismatch( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + """A well is not a site, and the reference walk says so before authorization.""" + from labwire.core import UnknownReferenceError + + _rig, client = served + with pytest.raises(UnknownReferenceError) as caught: + await client.submit( + "move_plate", + {"plate": "labwire:deck/target_plate", "to": "labwire:deck/source_plate/A1"}, + ) + assert caught.value.details is not None + assert caught.value.details["reason"] == "kind_mismatch" + + +async def test_gripper_moves_are_not_interruptible( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + _rig, client = served + descriptor = await client.describe() + movers = {c.name: c.interruptible for c in descriptor.commands} + assert movers["move_plate"] is False + assert movers["move_lid"] is False + assert movers["move_resource"] is False + + +async def test_the_manifest_of_a_granted_run_records_the_ceremony( + rig: LiquidHandler, tmp_path: Path +) -> None: + from datetime import timedelta + + from labwire.core import AuthorizationRequiredError, verify_bundle + + server = InstrumentServer( + PyLabRobotInstrument(rig), + confirmation_token=GRANT, + manifest_dir=tmp_path, + grant_store=tmp_path / "grants", + ) + client_end, server_end = MemoryTransport.pair() + server.attach(server_end) + async with LabwireClient.attach(client_end) as client: + params = {"plate": "labwire:deck/target_plate", "to": "labwire:deck/staging-0"} + with pytest.raises(AuthorizationRequiredError) as refused: + await client.submit("move_plate", params) + assert refused.value.details is not None + store = server._grant_store # pyright: ignore[reportPrivateUsage] + assert store is not None + grant = store.approve( + refused.value.details["request_id"], + now=server.clock.now(), + ttl=timedelta(minutes=15), + max_uses=1, + issued_by="priya", + ) + handle = await client.submit("move_plate", params, authorization=grant.grant_id) + await handle.result(timeout=20.0) + bundle = tmp_path / handle.command_id + await server.aclose() + + assert verify_bundle(bundle).ok + manifest = json.loads((bundle / "manifest.json").read_text()) + assert manifest["command"]["safety_class"] == "S3" + auth = manifest["authorization"] + assert auth["mode"] == "grant" + assert auth["identity_verified"] is False + assert auth["use_index"] == 1 + assert auth["issued_by"] == "priya" + assert "grant_id" not in json.dumps(manifest) # a bearer value never lands in a bundle + assert manifest["command"]["params_digest"].startswith("sha256:") + + +# --- discovery hygiene: the mechanical guards behind the no-hint claim ------ +# +# The claim is that discovery ALONE leads an agent to the deck resource. These +# tests cannot prove model behaviour, and do not try; they prove the +# preconditions hold and cannot silently rot: nothing in the descriptor gives +# an agent material to invent a reference from, and the pointer to the deck +# rides inside every reference-taking parameter. + + +async def test_no_reference_parameter_declares_a_pattern( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + """A pattern is satisfiable by invention; none may ride with a reference.""" + _rig, client = served + descriptor = await client.describe() + for spec in descriptor.commands: + for path, _ref in spec.references(): + assert "pattern" not in str(spec.params_schema), (spec.name, path) + + +async def test_every_reference_points_at_a_declared_resource( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + _rig, client = served + descriptor = await client.describe() + declared = {r.uri: set(r.item_kinds) for r in descriptor.resources} + for spec in descriptor.commands: + for path, ref in spec.references(): + assert ref["enumerated_by"] in declared, (spec.name, path) + assert ref["kind"] in declared[ref["enumerated_by"]], (spec.name, path) + + +async def test_the_descriptor_contains_no_labware_names( + served: tuple[LiquidHandler, LabwireClient], +) -> None: + """What mechanically prevents reintroducing an address grammar. + + Labware names live in resource state, not in discovery, so the descriptor + holds no material to assemble a reference value from. The only + constructible prefix in an agent's context is labwire:deck, which is a + read, not a submit. + """ + rig, client = served + descriptor = await client.describe() + text = descriptor.model_dump_json() + for name in {child.name for child in rig.deck.children}: + if "_" in name or "-" in name: + # A user-styled identifier appearing anywhere is a leak: it is the + # exact spelling a URI needs. + assert name not in text, f"descriptor leaks labware name {name!r}" + else: + # A common word (trash, tips, staging) may appear as English; what + # must not appear is any path spelling of it. + for spelled in (f"/{name}", f"{name}/"): + assert spelled not in text, f"descriptor spells a path with {name!r}" + + +def test_the_agent_demo_prompt_contains_no_discovery_hints() -> None: + """The specification must not live in the prompt (finding F2). + + The residual prompt may state the goal, the technique, and the safety + facts. It may not tell the agent to read the deck first, name the deck + resource, or spell any labware name the way a URI needs. + """ + import re + from pathlib import Path + + source = Path(__file__).parents[3].parents[0] / "examples" / "liquid_handling" + text = (source / "claude_dilution.py").read_text() + match = re.search(r'SYSTEM_PROMPT = f?"""(.*?)"""', text, re.DOTALL) + assert match is not None + prompt = match.group(1).lower() + for hint in ( + "labwire:", + "deck", + "describe", + "resource", + "read", + "source_plate", + "dilution_plate", + "target_plate", + "tips/", + ): + assert hint not in prompt, f"prompt still hints: {hint!r}" diff --git a/packages/mcp/src/labwire/mcp/server.py b/packages/mcp/src/labwire/mcp/server.py index 00eff0a..55e81c6 100644 --- a/packages/mcp/src/labwire/mcp/server.py +++ b/packages/mcp/src/labwire/mcp/server.py @@ -18,10 +18,10 @@ from typing import Any, cast from labwire.core import CommandSpec, InstrumentDescriptor, LabwireClient, LabwireError -from labwire.core.capabilities import CONFIRMATION_REQUIRED_CLASSES +from pydantic import AnyUrl from mcp.server.lowlevel import Server -from mcp.types import TextContent, Tool +from mcp.types import Resource, TextContent, Tool, ToolAnnotations _DEFAULT_TIMEOUT_S = 300.0 @@ -76,7 +76,15 @@ async def connect_instruments(urls: list[str]) -> list[ConnectedInstrument]: ), "S3": ( "Safety class S3 (HAZARDOUS, capable of harming people or equipment). " - "Requires a `confirmation` value; do not invent one." + "This tool does NOT accept a confirmation string and a session " + "confirmation will not authorize it. It requires an operator grant, " + "provisioned outside this protocol and bound to this command and these " + "exact parameter values, expiring and use-limited. You cannot create, " + "request, or derive one. If you do not hold a grant for these exact " + "values, call this tool once WITHOUT authorization: the server will " + "refuse it and return a request id and the exact command a human " + "operator must run. Report that to your operator and stop. Never " + "invent a grant id." ), } @@ -99,26 +107,65 @@ def _tool_description(instrument: ConnectedInstrument, spec: CommandSpec) -> str def _tool_input_schema(spec: CommandSpec) -> dict[str, Any]: - """The command's params schema, plus ``confirmation`` for S2/S3 (SPEC §8.6).""" - if spec.safety_class not in CONFIRMATION_REQUIRED_CLASSES: - return spec.params_schema - schema = deepcopy(spec.params_schema) - properties = schema.setdefault("properties", {}) - if isinstance(properties, dict): - cast("dict[str, Any]", properties)["confirmation"] = { - "type": "string", - "description": ( - f"Operator confirmation required for this {spec.safety_class} command. " - "Use the confirmation string the operator supplied for this session." - ), - } - required = schema.get("required") - schema["required"] = ( - [*cast("list[Any]", required), "confirmation"] - if isinstance(required, list) - else ["confirmation"] - ) - return schema + """The command's params schema, plus its authorization field (SPEC §8.6). + + S2 adds a required ``confirmation``. S3 adds ``authorization``, and + deliberately **not** required: the discovery-by-refusal first call must be + a well-formed tool call a host will not block, and the two fields are + never both present, because a confirmation cannot satisfy S3. + """ + if spec.safety_class == "S2": + schema = deepcopy(spec.params_schema) + properties = schema.setdefault("properties", {}) + if isinstance(properties, dict): + cast("dict[str, Any]", properties)["confirmation"] = { + "type": "string", + "description": ( + "Operator confirmation required for this S2 command. " + "Use the confirmation string the operator supplied for this session." + ), + } + required = schema.get("required") + schema["required"] = ( + [*cast("list[Any]", required), "confirmation"] + if isinstance(required, list) + else ["confirmation"] + ) + return schema + if spec.safety_class == "S3": + schema = deepcopy(spec.params_schema) + properties = schema.setdefault("properties", {}) + if isinstance(properties, dict): + cast("dict[str, Any]", properties)["authorization"] = { + "type": "object", + "additionalProperties": False, + "required": ["grant_id"], + "description": ( + "Operator grant. You cannot mint this. Present only an id an " + "operator gave you for this exact call." + ), + "properties": {"grant_id": {"type": "string"}}, + } + return schema + return spec.params_schema + + +def _tool_annotations(spec: CommandSpec) -> ToolAnnotations | None: + """MCP hints for the upper classes; unset elsewhere rather than asserted. + + ``readOnlyHint`` stays unset even for S0/S1 because Labwire cannot yet + tell a read from a state edit (finding F6, out of scope), and unset says + unknown rather than asserting something false. + """ + if spec.safety_class == "S3": + return ToolAnnotations( + title="HAZARDOUS: operator grant required", + destructiveHint=True, + idempotentHint=False, + ) + if spec.safety_class == "S2": + return ToolAnnotations(title="Irreversible: confirmation required", destructiveHint=True) + return None def build_server(instruments: list[ConnectedInstrument]) -> Server: # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType] @@ -131,21 +178,86 @@ def build_server(instruments: list[ConnectedInstrument]) -> Server: # pyright: @server.list_tools() async def _list_tools() -> list[Tool]: # pyright: ignore[reportUnusedFunction] - return [ + tools = [ Tool( name=tool_name, description=_tool_description(instrument, spec), inputSchema=_tool_input_schema(spec), + annotations=_tool_annotations(spec), ) for instrument in instruments for tool_name, spec in instrument.commands.items() ] + for instrument in instruments: + if not instrument.descriptor.resources: + continue + # The read is model-callable, not host-optional: MCP resources are + # application-controlled and many hosts surface them only through + # a human-driven picker, so a discovery story cannot rest on them + # alone. The uri parameter is an enum, so the model cannot get it + # wrong. + tools.append( + Tool( + name=f"{instrument.prefix}__read_resource", + description=( + "Read one of this instrument's resources: its typed content " + "and the index of everything a command parameter can " + "reference. " + + " ".join( + f"{r.uri}: {r.description}" for r in instrument.descriptor.resources + ) + ), + inputSchema={ + "type": "object", + "additionalProperties": False, + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "enum": [r.uri for r in instrument.descriptor.resources], + } + }, + }, + annotations=ToolAnnotations(readOnlyHint=True), + ) + ) + return tools + + @server.list_resources() + async def _list_resources() -> list[Resource]: # pyright: ignore[reportUnusedFunction] + # The namespaced form exists only where MCP requires a globally unique + # uri; reference values everywhere the model acts keep the wire + # spelling, so there is no bidirectional rewriting to get wrong. + return [ + Resource( + uri=AnyUrl(f"labwire://{instrument.prefix}/{spec.uri.removeprefix('labwire:')}"), + name=f"{instrument.prefix}: {spec.title}", + description=spec.description, + mimeType="application/json", + ) + for instrument in instruments + for spec in instrument.descriptor.resources + ] + + @server.read_resource() + async def _read_resource(uri: AnyUrl) -> str: # pyright: ignore[reportUnusedFunction] + text = str(uri) + for instrument in instruments: + marker = f"labwire://{instrument.prefix}/" + if text.startswith(marker): + wire_uri = "labwire:" + text.removeprefix(marker) + snapshot = await instrument.client.read_resource(wire_uri) + return snapshot.model_dump_json(exclude_none=True) + raise ValueError(f"unknown resource: {uri}") @server.call_tool() async def _call_tool( # pyright: ignore[reportUnusedFunction] name: str, arguments: dict[str, Any] ) -> list[TextContent]: for instrument in instruments: + if name == f"{instrument.prefix}__read_resource": + snapshot = await instrument.client.read_resource(str(arguments["uri"])) + return [TextContent(type="text", text=snapshot.model_dump_json(exclude_none=True))] spec = instrument.commands.get(name) if spec is not None: return await _run_command(instrument, spec, arguments) @@ -164,14 +276,34 @@ async def _run_command( ) payload = dict(arguments) confirmation = payload.pop("confirmation", None) + authorization = cast("dict[str, Any] | None", payload.pop("authorization", None)) + grant_id = authorization.get("grant_id") if isinstance(authorization, dict) else None try: handle = await instrument.client.submit( spec.name, payload, confirmation=str(confirmation) if confirmation is not None else None, + authorization=str(grant_id) if grant_id is not None else None, ) result = await handle.result(timeout=timeout) except LabwireError as exc: + if exc.details: + # Flattening to str(exc) would destroy request_id, did_you_mean, + # and the ready-to-send read request; the recovery paths the + # protocol designed live in these fields, so the model gets them. + return [ + TextContent( + type="text", + text=json.dumps( + { + "error": str(exc), + "category": exc.category, + "retryable": exc.retryable, + "details": exc.details, + } + ), + ) + ] raise ValueError( f"{exc.category} error from {instrument.descriptor.identity.model}: {exc} " f"(retryable: {exc.retryable})" diff --git a/tests/test_demo.py b/tests/test_demo.py index d90a607..cfdeffc 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -69,16 +69,34 @@ def test_ophyd_claude_scan_degrades_gracefully_without_key(tmp_path: Path) -> No assert "OK - authentic" in proc.stdout -def test_pylabrobot_dilution_demo_runs_and_signs_it(tmp_path: Path) -> None: +def test_pylabrobot_dilution_demo_runs_the_full_s3_ceremony(tmp_path: Path) -> None: proc = _run_demo("demo-pylabrobot", tmp_path, strip_key=False) assert proc.returncode == 0, proc.stderr assert "S2 transfer" in proc.stdout # safety classes are surfaced + assert "S3 move_plate" in proc.stdout assert "nominal 1:2" in proc.stdout - assert "OK - authentic" in proc.stdout + # the four beats, in order: refused absent, operator approves, granted, + # then the same valid grant refused on different parameters + out = proc.stdout + refused = out.index("REFUSED -32011 reason=absent") + approved = out.index("labwire grant approve") + granted = out.index("GRANTED") + mismatch = out.index("reason=params_mismatch") + assert refused < approved < granted < mismatch + assert "OK - authentic" in out + assert "identity_verified False" in out + # a bearer grant id never lands in a signed bundle + import json as _json + + for manifest_path in (tmp_path / "runs").glob("*/manifest.json"): + assert "grant_id" not in _json.dumps(_json.loads(manifest_path.read_text())) def test_pylabrobot_claude_dilution_degrades_gracefully_without_key(tmp_path: Path) -> None: proc = _run_demo("demo-pylabrobot-claude", tmp_path, strip_key=True) assert proc.returncode == 0, proc.stderr assert "ANTHROPIC_API_KEY not set" in proc.stdout + # the scripted fallback exercises the same ceremony CI asserts above + assert "reason=absent" in proc.stdout + assert "reason=params_mismatch" in proc.stdout assert "OK - authentic" in proc.stdout From 9a4d42cd287ef78acb11966a96c196723077c5c1 Mon Sep 17 00:00:00 2001 From: Silous Ramelli <204268110+TheRoboMaster123@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:40:00 -0700 Subject: [PATCH 5/5] docs: v0.3, findings resolved, changelog, migration notes SPEC-FINDINGS marks F1, F2, and F4 resolved in place, each with what was actually built, what was rejected on the way (the sidecar map F1 itself recommended lost to the in-schema keyword; ed25519 minting lost to a plain store), and its residual stated plainly: the kind registry is governed by nobody yet, there is no pagination and no reservation, a grant id is a bearer value and identity is still not proven. Findings are history, so nothing was deleted. CHANGELOG 0.3.0.dev0 with the full breaking list and migration notes per audience. ROADMAP's modeling-things section becomes shipped-with- residuals and keeps F3, F6, F7 explicitly out. PRIOR_ART credits what v0.3 borrows: WoT Thing Description's semantics-in-the-schema and the unit term, JSON-LD's intuition without its machinery, MCP's resource primitive reduced, and LAP's parameter-digest binding, with LAP's JWS tokens still named as the more complete design. Package versions to 0.3.0.dev0. The bridge README teaches URIs and the grant ceremony, and its LIMITATIONS draws the line that matters: command-level S3 is enforced now, resource-level hazard still is not, because per-argument classes are finding F3 and out of scope. Co-Authored-By: Claude --- CHANGELOG.md | 89 +++++++++++++++++ PRIOR_ART.md | 24 +++++ README.md | 15 ++- ROADMAP.md | 47 ++++----- SPEC-FINDINGS.md | 80 ++++++++++++--- packages/bridges/ophyd/pyproject.toml | 2 +- packages/bridges/pylabrobot/README.md | 109 +++++++++++++-------- packages/bridges/pylabrobot/pyproject.toml | 2 +- packages/cli/pyproject.toml | 2 +- packages/core/pyproject.toml | 2 +- packages/drivers/pyproject.toml | 2 +- packages/mcp/pyproject.toml | 2 +- packages/sim/pyproject.toml | 2 +- uv.lock | 14 +-- 14 files changed, 296 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a365f8b..66ad50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,95 @@ All notable changes to Labwire. The protocol version (`"0.2"`) and the package versions move together while the project is pre-1.0; breaking changes are expected until then, and are called out explicitly. +## 0.3.0.dev0, unreleased + +Protocol version `"0.3"`: things, not only quantities. Driven by findings +F1, F2, and F4 in [SPEC-FINDINGS.md](SPEC-FINDINGS.md), each now resolved +there with its residual stated. + +### Added + +- **Resources** (SPEC §7.6, §10): URI-identified, typed, readable instrument + state, declared in the descriptor beside commands and read with + `resource/read`. Content schemas carry a scoped `unit` keyword so state is + as unit-mandatory as commands. Revisions are derived from the canonical + read result; `resource/changed` rides the event channel under a reserved + name. The liquid handler's deck is `labwire:deck`; the syringe pump gains + `labwire:syringe`, a consumable resource on an instrument with no + references at all, because the primitive is not deck-shaped. +- **Typed references** (SPEC §7.2): the `resource_ref` schema keyword, with + `kind` matched against a registry (SPEC Appendix A) and `enumerated_by` + naming the resource whose index lists valid values. Closure is checked + before a descriptor is served; values resolve against a fresh read at + submission; the refusal (`-32010`) carries an RFC 6901 pointer, the + expected kind, the longest resolving prefix, `did_you_mean`, and a + ready-to-send read request. The SDK's `ResourceRef(...)` builds annotated + parameter types, so a bridge writes `source: Container` with no regex. +- **Operator grants for S3** (SPEC §8.6): provisioned out of band in a store + the protocol has no method to write, bound to a command name and the RFC + 8785 digest of its normalized parameters (a binding adopted from LAP with + credit), expiring and use-limited, consumed atomically. A refused S3 + submission records a pending request; `labwire grant list | approve | + revoke` is the operator tool; the refusal (`-32011`) says + `mintable_by_agent: false` in a typed field. A server declaring S3 + commands with no store refuses to start. +- **Optimistic concurrency** (SPEC §10.5): `if_revision` on submit, refused + with `-32012` before any confirmation or grant is spent; terminal status + carries `resource_revisions`, so a single agent never re-reads between + steps. +- **Gripper moves** in `labwire-pylabrobot`: `move_plate`, `move_lid`, + `move_resource` at S3, non-interruptible, with resource-typed parameters. + The demos show the ceremony beat by beat, ending with a valid grant + refused on different parameters. Exercised against the chatterbox backend + only, **never against physical hardware**. +- The MCP adapter maps resources onto MCP resources, synthesizes a + model-callable read tool with an enum `uri`, distinguishes S2 confirmation + from S3 authorization in schemas and descriptions, and serializes error + details instead of flattening them. + +### Breaking + +- Protocol version is `"0.3"`; a v0.2 client and a v0.3 server do not + interoperate. +- `InstrumentDescriptor.resources` is REQUIRED of servers (`[]` allowed). +- **A `confirmation` no longer satisfies `S3`.** Deployments that raised a + command to S3 stop working until grants are provisioned; the failure is + loud (`-32011`, reason `absent`), never silent. +- Submission precedence moves `interlock` and capacity ahead of + confirmation and authorization: everything knowable without an operator + is checked first (SPEC §12.1). A submit against a tripped interlock now + returns `-32003` where v0.2 returned `-32009`. +- The error `data` requirement extends to `-32012` (SPEC §12.2). +- **Manifests are `"0.3"`**: `command.params` records the **normalized** + parameters (v0.2 recorded the raw submission, so a command with defaulted + optionals signed a manifest describing something other than what ran), + plus `params_digest`, an `authorization` block with REQUIRED + `identity_verified: false`, and `resource_revisions`. Verifiers accept + 0.2 and 0.3 bundles both; `labwire verify` refuses a 0.3 bundle claiming + identity was verified. +- The `unit` and `resource_ref` schema keywords are claimed: `unit` + REQUIRED on numeric nodes in `content_schema` and forbidden in command + schemas; `resource_ref` permitted only in `params_schema`, never beside a + `pattern`. +- `labwire-pylabrobot`: `describe_deck` is deleted (the deck is a + resource); the `"plate/A1"` address grammar is deleted (references are + `labwire:deck/...` URIs); the annotation file keys `resources:` by URI + and loses its per-resource `safety_class`, which was documented three + times as reported-but-not-enforced. + +### Migration + +- Instruments with no tree-shaped state: rebuild against 0.3 and change + nothing; the SDK supplies `resources: []`. +- Instruments that exposed state through a command result: declare a + `resource(...)` with a content model, move the command's body into its + `@reader`, and delete the command. +- Deployments using S3: provision a grant store (`grant_store=` or + `LABWIRE_GRANT_STORE`) and approve requests with `labwire grant`. +- Clients: read `resources` from the descriptor; follow `enumerated_by` + from any `resource_ref` you cannot fill; treat `-32010`/`-32011`/`-32012` + per their `details`, which carry the recovery paths. + ## 0.2.1, 2026-07-27 Protocol version stays `"0.2"`: no message shape changed. diff --git a/PRIOR_ART.md b/PRIOR_ART.md index 535764c..2c93ca9 100644 --- a/PRIOR_ART.md +++ b/PRIOR_ART.md @@ -68,6 +68,30 @@ blocks, JSON-LD/WoT-profiled capability documents, and the full operator token binding. All are roadmap candidates for Labwire, listed rather than claimed. +## W3C Web of Things Thing Description, and JSON-LD + +Protocol v0.3's typed references and content units borrow one structural +idea from **WoT Thing Description**: semantics belong *inside* an +interaction affordance's data schema, not in a side table. That is why +`resource_ref` and the content `unit` keyword ride on schema nodes rather +than in sidecar maps, and the `unit` term itself follows TD practice. +From **JSON-LD**, only the intuition that a value can be a typed link to a +named node rather than a literal. Labwire is **not** JSON-LD: there is no +`@context`, `labwire:` URIs are not IRIs into a shared vocabulary, and +`kind` is matched within one instrument against a registry this project +maintains alone. The intuition is borrowed; the machinery is deliberately +not, because plain JSON Schema descriptors are what make Labwire commands +directly consumable as MCP tool schemas. LAP profiles its InstrumentCard on +WoT TD and is genuinely JSON-LD; that is the more standards-aligned design, +at a cost in plainness this project chose not to pay yet. + +**MCP resources** are the direct ancestor of Labwire's resource primitive, +reduced: declaration rides the descriptor rather than a list method, one +read method, revisions instead of subscriptions. And v0.3's operator grants +keep **LAP's** binding of an authorization to the capability and the SHA-256 +of canonical parameters, dropping (for now) the JWS signature; LAP's +cryptographically bound operator tokens remain the more complete design. + ## SCP (Science Context Protocol) **What it is:** "SCP: Accelerating Discovery with a Global Web of diff --git a/README.md b/README.md index 559829a..ceab259 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ lab equipment": one universal way for AI agents to **discover** an instrument's capabilities, **command** it, **stream** its measurements, and walk away with **cryptographically signed** proof of what was done. -> Working title, protocol v0.2 draft. The wire protocol will change before +> Working title, protocol v0.3 draft. The wire protocol will change before > 1.0. Feedback and prior-art corrections are very welcome; see > [CONTRIBUTING.md](CONTRIBUTING.md). @@ -28,9 +28,16 @@ buildable now: the exact telemetry recorded: portable, tamper-evident evidence of what instrument did what, verified by one CLI command. - **Safety and physical typing in the protocol.** Mandatory UCUM units on - every quantity, S0-S3 safety classes with confirmation required for - irreversible or hazardous actions, interlocks, cancellation, and typed - errors with retryability. All specified in the protocol, not vendor add-ons. + every quantity, S0-S3 safety classes where irreversible actions take an + operator confirmation and hazardous ones take an **operator grant an agent + cannot mint**, bound to the exact parameters, interlocks, cancellation, and + typed errors with retryability. All specified, not vendor add-ons. +- **Things, not only quantities.** v0.3 adds **resources** (typed, readable + instrument state, like a liquid handler's deck) and **typed references** + (parameters that name a well or a site, validated against current state, + with errors that hand the agent the read that recovers). Designed so that + discovery alone leads an agent to the deck, with no prompt coaching; CI + enforces the preconditions, and the demo asserts the behaviour. - **Runnable by a stranger in 5 minutes.** Zero hardware: the reference implementation ships three realistic simulated instruments. diff --git a/ROADMAP.md b/ROADMAP.md index 3891172..ad2511b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,34 +24,27 @@ conformance table (SPEC §15.2) rather than implied to exist. ## Modeling things, not only quantities -Everything in this section comes from [SPEC-FINDINGS.md](SPEC-FINDINGS.md), -the record of where protocol v0.2 strained while the PyLabRobot bridge was -being built. That document has the reasoning and the failing cases; this is -the work. +Protocol v0.3 shipped the heart of this section: **resources** (typed, +URI-identified, readable state, declared in discovery), **typed references** +(the `resource_ref` keyword, validated against current state at submission), +and **operator grants** (S3 authorization an agent cannot mint, bound to the +LAP-credited parameter digest), plus `if_revision` optimistic concurrency. +See [SPEC-FINDINGS.md](SPEC-FINDINGS.md) F1, F2, and F4 for what was built +and the residuals. What remains here is deliberately deferred: -- **Typed resource references** (finding F1, blocking). Units made a parameter - a volume in microlitres; nothing makes a parameter a well that exists on - this deck. A `reference_annotations` map declaring what kind of thing a - parameter names, and the command that enumerates the valid values, plus an - `unknown_reference` error category. Without it every bridge invents its own - address grammar, which is the fragmentation Labwire exists to end. -- **A state document** (finding F2, blocking). `state/get`, a `state_schema` - in the descriptor, `notifications/state_changed` with a revision, and a - `state_revision` on command results so an agent can tell whether the deck it - planned against is the deck it acted on. Instrument state that is a tree has - nowhere to live today, so it goes in a command result that nothing marks as - special. -- **Argument-dependent safety classes** (finding F3). Let a server compute an - effective class from validated parameters, bounded below by the declared - class, and report it in the `-32009` error. Dispensing into waste and - dispensing into a live culture are currently the same command. -- **An `effects` declaration** (finding F6), orthogonal to S0 to S3, so - operations that change only the instrument's model of the world can be - described without misusing a scale that grades physical consequence. -- **Preconditions in the descriptor** (finding F7), or at minimum an interlock - error that names the command clearing it in a structured field, so an agent - can order a plan correctly on the first attempt instead of discovering it by - failing. +- **Kind registry governance.** SPEC Appendix A is seeded from one domain + and maintained by one project; a process for admitting kinds, and evidence + that a second ecosystem can express its references in the same vocabulary, + are both open. +- **Cryptographic operator identity.** A JWS operator token signed over the + task and parameter digest, with key distribution and revocation, as LAP + specifies. v0.3 grants prove deployment policy plus parameter binding plus + a bounded window; they do not prove who. +- **Resource index pagination.** A 1536-well plate is fine; a plate hotel of + thousands of positions has no good answer yet. +- **Argument-dependent safety classes** (finding F3), an **effects + declaration** (F6), and **preconditions in the descriptor** (F7): still + out of scope, still recorded in SPEC-FINDINGS. ## Physical typing diff --git a/SPEC-FINDINGS.md b/SPEC-FINDINGS.md index aaf2061..7d1b196 100644 --- a/SPEC-FINDINGS.md +++ b/SPEC-FINDINGS.md @@ -12,12 +12,12 @@ on the deck this morning. Its interesting state is a tree. Building the whether the capability model generalizes, and this file is the result: everything that strained, written down while it was straining. -Nine findings. Two are serious enough that a v0.3 which ignored them would be -a protocol for detectors wearing the clothes of a general standard. Three are -small and cheap, and one of those (F5, a hole in the mandatory-unit guarantee) -was serious enough to fix immediately and shipped in 0.2.1. The last section -lists what did *not* strain, which matters just as much and is the part a -findings document usually leaves out. +Nine findings. The two blocking ones (F1, F2) and the enforcement gap behind +the safety story (F4) drove protocol v0.3 and are resolved there, each with +its residual stated in place. F5 was a hole in the v0.2 headline guarantee, +fixed immediately in 0.2.1. The last section lists what did *not* strain, +which matters just as much and is the part a findings document usually +leaves out. Findings are kept here after they are fixed, marked resolved with what changed. This file is a record of what the protocol got wrong, not a task @@ -25,10 +25,10 @@ list; the work itself is scheduled in [ROADMAP.md](ROADMAP.md). | | Finding | Severity | |---|---|---| -| F1 | Resource references cannot be typed the way numbers can | blocking | -| F2 | State that is a tree has nowhere to live | blocking | +| F1 | Resource references cannot be typed the way numbers can | **resolved in 0.3** | +| F2 | State that is a tree has nowhere to live | **resolved in 0.3** | | F3 | Safety class is static when the risk is in the arguments | significant | -| F4 | S2 and S3 are indistinguishable in enforcement | significant | +| F4 | S2 and S3 are indistinguishable in enforcement | **resolved in 0.3** | | F5 | Mandatory units do not recurse into arrays | **resolved in 0.2.1** | | F6 | Operations with no physical consequence have no class | small | | F7 | Preconditions are discoverable only by failing | small, cheap | @@ -39,7 +39,27 @@ list; the work itself is scheduled in [ROADMAP.md](ROADMAP.md). ## F1. Resource references cannot be typed the way numbers can -**Severity: blocking.** This is the finding that generalizes furthest. +**Severity: blocking. RESOLVED in protocol v0.3.** This is the finding that +generalized furthest, and it drove the version. + +> **Resolved 2026-07-27.** v0.3 gives references the treatment units got, +> though not in the shape this finding recommended: instead of a sidecar +> `reference_annotations` map, the `resource_ref` keyword rides on the +> parameter's own schema node, following W3C Thing Description practice of +> putting semantics inside the affordance's data schema. The sidecar form +> was tried on paper and rejected because every adapter would have to +> remember to flatten it into prose, and it would hit the same nested-object +> wall `unit_annotations` did. The bridge's invented grammar is deleted; +> URIs compose by one protocol rule from a read result's index; the server +> validates every reference against a fresh read at submission and refuses +> with `-32010`, an RFC 6901 pointer, the expected kind, the longest +> resolving prefix, did_you_mean candidates, and a ready-to-send read +> request. **Residual, stated plainly:** the kind vocabulary (SPEC Appendix +> A) is seeded from one domain and governed by nobody; until a second +> ecosystem adopts it, cross-instrument portability of kinds is a design +> intention, not a demonstrated fact. And validation is +> time-of-check-to-time-of-use; `if_revision` narrows the window and +> nothing closes it. Protocol v0.2 made a real advance by requiring UCUM units: a parameter is no longer "a number", it is a volume in microlitres, and a client that confuses @@ -100,7 +120,24 @@ requires it to admit that some parameters point at instrument state. ## F2. State that is a tree has nowhere to live -**Severity: blocking.** +**Severity: blocking. RESOLVED in protocol v0.3.** + +> **Resolved 2026-07-27.** Resources are the home: declared in the +> descriptor beside commands so discovery is not skippable, URI-identified, +> read with `resource/read`, revisioned so staleness is detectable, and +> indexed so typed references have something protocol-defined to resolve +> against. The deck is `labwire:deck` now and `describe_deck` is deleted. +> Change notification rides the event channel under a reserved name; +> terminal command status returns the revisions the run changed, so a +> single agent never re-reads between steps; `if_revision` refuses a stale +> plan before any confirmation or grant is spent. The agent demo's prompt +> no longer mentions the deck at all, and CI enforces that the prompt stays +> hint-free and the descriptor leaks no labware names; whether discovery +> alone actually leads a live model to the deck is a claim about model +> behaviour, asserted in the demo rather than in CI, and the README words +> it as designed-for, not verified. **Residual, stated plainly:** no +> pagination (a plate hotel of thousands of positions has no good answer +> yet), no reservation, and no lost-update protection beyond `if_revision`. An agent cannot plan a transfer without knowing what is on the deck. Labwire v0.2 has exactly three places to put information, and the deck fits none: @@ -212,7 +249,26 @@ run. ## F4. S2 and S3 are indistinguishable in enforcement -**Severity: significant.** Known before this bridge; made concrete by it. +**Severity: significant. RESOLVED in protocol v0.3.** Known before the +bridge; made concrete by it; fixed by making the classes different +mechanisms rather than different labels. + +> **Resolved 2026-07-27.** S2 keeps the session confirmation. S3 takes an +> operator grant an agent structurally cannot produce: provisioned in a +> server-side store the protocol has no method to write, bound to the +> command and the RFC 8785 digest of its normalized parameters (LAP's +> binding, credited), expiring, use-limited, consumed atomically. The +> refusal records a pending request so the operator's approval tool reads +> the real parameters from the server's own store, never from a digest +> relayed through the agent that wants the approval, which closes the +> digest-laundering hole found during design review. The manifest records +> the ceremony with a REQUIRED `identity_verified: false`, and the demo +> shows the beat that matters: a valid, unexpired grant refused on +> different parameters. **Residual, stated plainly:** a grant id is a +> bearer value, `issued_by` is an unauthenticated label, and on one machine +> nothing separates operator from agent but file permissions. v0.3 proves +> deployment policy plus parameter binding plus a bounded window, and still +> not identity; JWS operator tokens remain the successor. v0.2 gates S2 and S3 through the same confirmation stub. A correct confirmation satisfies both. So raising a command to S3 changes what is diff --git a/packages/bridges/ophyd/pyproject.toml b/packages/bridges/ophyd/pyproject.toml index c6a3e4f..f17a156 100644 --- a/packages/bridges/ophyd/pyproject.toml +++ b/packages/bridges/ophyd/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-ophyd" -version = "0.2.1" +version = "0.3.0.dev0" description = "Expose ophyd (Bluesky) devices as Labwire instruments" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/bridges/pylabrobot/README.md b/packages/bridges/pylabrobot/README.md index 1b712a9..e739003 100644 --- a/packages/bridges/pylabrobot/README.md +++ b/packages/bridges/pylabrobot/README.md @@ -16,6 +16,18 @@ interesting state is a tree. Everywhere the protocol strained is written down in [SPEC-FINDINGS.md](../../../SPEC-FINDINGS.md), which is the real output of this package. +## What changed in v0.3 + +The deck stopped being a command and became the `labwire:deck` **resource**: +read it for typed content plus the index of everything a command parameter +can reference. The invented `"plate/A1"` grammar is gone; references are +URIs like `labwire:deck/source_plate/A1`, composed by one protocol-defined +rule, declared with the `resource_ref` keyword instead of a regex, and +validated by the server against a fresh read before a handler runs. And the +**gripper ships**: `move_plate`, `move_lid`, `move_resource` at S3, which +now means an operator grant an agent cannot mint, bound to the exact +parameters of one call. + ## Five-minute quickstart Nothing here needs hardware, a server, or a browser. From a checkout with @@ -39,14 +51,14 @@ every piece of labware with what the annotation file says it holds. ``` OK: LiquidHandlerChatterboxBackend (lh_deck) - 8 channel(s), 8 piece(s) of labware - S0 describe_deck + 8 channel(s), 9 piece(s) of labware S2 aspirate S2 dispense S1 set_well_volume + S3 move_plate S0 stop - tips: tip_rack 8x12 (96 tips) - source_plate: plate 8x12 (hazard: none) + labwire:deck/tips: tip_rack 8x12 (96 tips) + labwire:deck/source_plate: plate 8x12 (hazard: none) ``` Serving it is a few lines: @@ -63,27 +75,30 @@ async with server.serve_websocket("127.0.0.1", 9520): ## Addressing -Everything an operation acts on is named `"/"`: +Everything an operation acts on is a URI under the deck resource: ``` -source_plate the plate itself -source_plate/A1 one well of it -tips/H12 one tip spot +labwire:deck the resource: read it +labwire:deck/source_plate labware standing on the deck +labwire:deck/source_plate/A1 one well of it +labwire:deck/tips/H12 one tip spot ``` -Two things are deliberately **not** accepted, both explained in -[DESIGN.md](DESIGN.md). PyLabRobot's derived names (`source_plate_well_A1`) -resolve internally but leak a naming rule an agent should not have to know, so -they are refused with the canonical address in the error. And PyLabRobot's -range syntax (`plate["A1:H1"]`) is not exposed, because JSON arrays already -carry cardinality and one way to say a thing is better than two: +Item URIs compose by the protocol rule (SPEC 10.1): entry URI, slash, an id +from the read result's index, so an agent that can read an index can +construct every legal reference with no grammar to learn. Two things are +deliberately **not** accepted: PyLabRobot's derived names +(`source_plate_well_A1`) are refused with the canonical URI, and its range +syntax (`plate["A1:H1"]`) is not exposed, because JSON arrays already carry +cardinality: ```json -{"wells": ["source_plate/A1", "source_plate/B1"], "volumes_ul": [50.0, 50.0]} +{"wells": ["labwire:deck/source_plate/A1"], "volumes_ul": [50.0]} ``` -Every failure names what would have worked. An unknown labware lists the deck; -an unknown well reports the grid shape. +Every failure names what would have worked, and the server's own refusal +(`-32010`) adds the pointer, the expected kind, did_you_mean candidates, +and a ready-to-send read request. ## The annotation file @@ -102,25 +117,29 @@ commands: labware: Cor_96_wellplate_360ul_Fb: {description: A Costar 96-well plate.} resources: - acid_stock: + labwire:deck/acid_stock: description: 1 M hydrochloric acid. hazard: corrosive - safety_class: S3 # reported and recorded, NOT enforced: see below - locked: true # refused outright, which v0.2 can enforce + locked: true # refused outright; enforced ``` Rules worth knowing: - **Merging is per field**, from the labware entry (keyed by PyLabRobot class - or model) to the resource entry (keyed by name), so an override touches only - what it names. + or model) to the resource entry (keyed by its deck URI), so an override + touches only what it names. - **Unknown keys, resources, and commands are errors.** An annotation naming a plate that is not on the deck is refused rather than ignored, because a silently dropped hazard annotation is the worst failure this file has. -- **`locked` is enforced. `safety_class` here is not.** Locking a plate locks - all 96 of its wells and refuses every operation touching them. Raising a - resource to S3 changes what is reported and recorded and nothing else, for - the reason in LIMITATIONS. +- **`locked` is enforced.** Locking a plate locks all 96 of its wells and + refuses every operation touching them. +- **There is no per-resource `safety_class` any more.** It was documented in + three places as reported-but-not-enforced, and keeping a field that cannot + raise a call's class would be keeping a lie: argument-dependent classes are + finding F3, still out of scope. Command-level `safety_class` overrides now + genuinely bite, because raising a command to S3 makes it require an + operator grant. `hazard` appears in the deck resource content, where an + agent actually reads it. ## Mapping @@ -128,12 +147,13 @@ Rules worth knowing: |---|---| | `LiquidHandler` | one instrument | | backend class name | `identity.model` | -| deck and its labware | `describe_deck` result, safety class **S0** | -| `Well` / `TipSpot` / `Plate` | an address string in command parameters | +| deck and its labware | the `labwire:deck` resource (`resource/read`) | +| `Well` / `TipSpot` / `Plate` | a `resource_ref`-typed URI in command parameters | | `aspirate` / `dispense` / `transfer` | commands, safety class **S2** | | `pick_up_tips` / `drop_tips` / `return_tips` / `discard_tips` | commands, **S2** | -| volume tracker, per well | `describe_deck` contents, listed sparsely | -| tip trackers, per channel | `describe_deck` channels | +| `move_plate` / `move_lid` / `move_resource` | commands, **S3**: operator grant, not interruptible | +| volume tracker, per well | deck resource content, listed sparsely | +| tip trackers, per channel | deck resource content | | cumulative volume moved | telemetry channels, in `uL` | | `stop` | command, safety class **S0** | | `NoTipError` / `HasTipError` | `interlock` | @@ -143,9 +163,13 @@ Rules worth knowing: | anything else | `hardware_fault` | Safety defaults lean toward friction. Everything that moves or consumes -material is S2, so an agent must present an operator confirmation for each -call; reads are S0; `stop` is S0 so recovery stays available while an -interlock is tripped. An annotation may raise a class, never lower it. +liquid is S2, so an agent must present an operator confirmation for each +call. Everything that moves **labware through space** is S3: the failure +mode is a collision, and each call takes a single-use operator grant bound +to its exact parameters, which the demo shows being refused, approved, +used, and then refused on different values. `stop` is S0 so recovery stays +available while an interlock is tripped. An annotation may raise a class, +never lower it. ## LIMITATIONS @@ -158,12 +182,19 @@ Read this before believing anything above. instrument**. PyLabRobot's simulator backend was removed in favour of a websocket Visualizer that opens a browser, so chatterbox is the only honest hardware-free option. -- **A hazard annotation is not enforced.** Labwire v0.2 gates S2 and S3 - through the same confirmation stub, so raising a resource to S3 changes what - is reported and recorded, not what is permitted. `locked` is the only - escalation this protocol version can actually enforce, which is why it is a - hard refusal rather than a gradation. See - [SPEC-FINDINGS.md](../../../SPEC-FINDINGS.md), findings F3 and F4. +- **Command-level S3 is enforced; resource-level hazard is not.** Getting + this distinction right matters more than the feature. An S3 *command* + requires a real operator grant now (finding F4, resolved). But annotating a + *resource* as hazardous still cannot raise the class of a call that touches + it, because safety classes are per command, not per argument: that is + finding F3, out of scope. `hazard` is surfaced to agents in the deck + content and `locked` is a hard refusal; neither is a gradation. +- **Gripper destinations are validated against the index, not against + physics.** A grant authorizes a move the operator saw; nothing here checks + reachability, collision clearance, or what a real STARlet's rail geometry + permits. - **Enabling tracking is process-wide.** The bridge turns on PyLabRobot's tip and volume trackers, because without them a liquid handler silently accepts physically impossible commands. PyLabRobot toggles both through module-level diff --git a/packages/bridges/pylabrobot/pyproject.toml b/packages/bridges/pylabrobot/pyproject.toml index c25c75a..778ba90 100644 --- a/packages/bridges/pylabrobot/pyproject.toml +++ b/packages/bridges/pylabrobot/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-pylabrobot" -version = "0.2.1" +version = "0.3.0.dev0" description = "Expose PyLabRobot liquid handlers as Labwire instruments" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index b0e9007..56ed74c 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-cli" -version = "0.2.1" +version = "0.3.0.dev0" description = "Labwire command-line tools: verify signed run bundles" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index fbddf1d..8851a7c 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-core" -version = "0.2.1" +version = "0.3.0.dev0" description = "Labwire core protocol library: server and client SDKs" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/drivers/pyproject.toml b/packages/drivers/pyproject.toml index 2a17260..5552272 100644 --- a/packages/drivers/pyproject.toml +++ b/packages/drivers/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-drivers" -version = "0.2.1" +version = "0.3.0.dev0" description = "Labwire instrument drivers speaking native wire protocols (SCPI/TCP, serial-style)" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index 8830cd9..186b134 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-mcp" -version = "0.2.1" +version = "0.3.0.dev0" description = "MCP adapter: expose Labwire instruments as MCP tools for AI agents" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/sim/pyproject.toml b/packages/sim/pyproject.toml index 1669200..6a7350c 100644 --- a/packages/sim/pyproject.toml +++ b/packages/sim/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "labwire-sim" -version = "0.2.1" +version = "0.3.0.dev0" description = "Labwire simulated instruments: realistic devices speaking native wire protocols" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 28fabcd..14705c4 100644 --- a/uv.lock +++ b/uv.lock @@ -525,7 +525,7 @@ wheels = [ [[package]] name = "labwire-cli" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/cli" } dependencies = [ { name = "labwire-core" }, @@ -540,7 +540,7 @@ requires-dist = [ [[package]] name = "labwire-core" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/core" } dependencies = [ { name = "pydantic" }, @@ -557,7 +557,7 @@ requires-dist = [ [[package]] name = "labwire-drivers" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/drivers" } dependencies = [ { name = "labwire-core" }, @@ -576,7 +576,7 @@ dev = [{ name = "labwire-sim", editable = "packages/sim" }] [[package]] name = "labwire-mcp" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/mcp" } dependencies = [ { name = "labwire-core" }, @@ -603,7 +603,7 @@ dev = [ [[package]] name = "labwire-ophyd" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/bridges/ophyd" } dependencies = [ { name = "labwire-core" }, @@ -639,7 +639,7 @@ dev = [ [[package]] name = "labwire-pylabrobot" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/bridges/pylabrobot" } dependencies = [ { name = "labwire-core" }, @@ -671,7 +671,7 @@ dev = [{ name = "pylabrobot", specifier = ">=0.2" }] [[package]] name = "labwire-sim" -version = "0.2.1" +version = "0.3.0.dev0" source = { editable = "packages/sim" } dependencies = [ { name = "labwire-core" },