diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6e918f..49a3f94 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()) == 12 print('pure layers import without pylabrobot') " diff --git a/CHANGELOG.md b/CHANGELOG.md index 3971408..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. @@ -152,7 +241,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/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 1878855..ad2511b 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. @@ -24,34 +24,27 @@ conformance table (SPEC §14.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/examples/liquid_handling/claude_dilution.py b/examples/liquid_handling/claude_dilution.py index 80081dc..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,31 +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. -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. - 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. +{{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( @@ -73,6 +71,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 @@ -82,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", @@ -90,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, @@ -112,32 +149,85 @@ 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] 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("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" ) @@ -156,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, @@ -176,15 +267,34 @@ 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}) 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]} ) @@ -208,11 +318,12 @@ 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)) - 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..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, ) @@ -49,16 +50,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 +84,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 +95,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,16 +116,34 @@ 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)") - 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/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..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,14 +123,97 @@ 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. 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/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/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..f61187f 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,29 @@ 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. - -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. +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 +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. """ @@ -107,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] """ @@ -123,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 """ @@ -137,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") @@ -152,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 """ @@ -162,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. @@ -189,6 +213,31 @@ 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", + "lid", + "container", + "tip_site", + "site", + ], + ) + tips_mounted = channel( "tips_mounted", unit="1", @@ -217,6 +266,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 +277,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 +333,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 +351,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 +376,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 +396,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 +416,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 +437,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) @@ -406,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: @@ -416,7 +509,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", @@ -425,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", } @@ -440,19 +535,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 +590,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}, @@ -540,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/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..a7af66c 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,25 @@ "carrier": "carrier", "plate_carrier": "carrier", "tip_carrier": "carrier", + "plate_holder": "site", + "lid": "lid", "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"], + "lid": ["lid", "labware"], + "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 +63,20 @@ class LabwareKind(enum.StrEnum): TROUGH = "trough" TRASH = "trash" CARRIER = "carrier" + SITE = "site" + LID = "lid" 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 +100,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 +119,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 +160,7 @@ class Unresolved(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - address: str + uri: str reason: UnresolvedReason message: str @@ -162,16 +190,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 +263,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 +275,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 +287,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 +298,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 +329,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.", @@ -364,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_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..a917655 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,15 +163,23 @@ 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_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: """Every PyLabRobot operation takes **backend_kwargs straight to vendor firmware.""" names = {c.name for c in command_surface()} 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: @@ -173,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 ef07eff..c9d18d3 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()}, ) @@ -166,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()}, @@ -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..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: @@ -54,14 +61,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 +104,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 +156,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 +171,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 +195,48 @@ 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)}) - server = InstrumentServer(PyLabRobotInstrument(rig, annotations), confirmation_token=GRANT) + annotations = AnnotationFile( + resources={"labwire:deck/source_plate": ResourceAnnotation(locked=True)} + ) + 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: 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 +268,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 +284,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 +300,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 +338,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 +354,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 +392,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() @@ -337,16 +413,19 @@ 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) 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) @@ -359,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/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/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/cli/src/labwire/cli/main.py b/packages/cli/src/labwire/cli/main.py index a8e9961..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, @@ -29,7 +34,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: @@ -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/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/core/src/labwire/core/__init__.py b/packages/core/src/labwire/core/__init__.py index fe137c4..e9e1950 100644 --- a/packages/core/src/labwire/core/__init__.py +++ b/packages/core/src/labwire/core/__init__.py @@ -12,12 +12,15 @@ from labwire.core._meta import PROTOCOL_VERSION, __version__ from labwire.core.capabilities import ( CONFIRMATION_REQUIRED_CLASSES, + KIND_REGISTRY, ChannelSpec, CommandSpec, IdentityInfo, InstrumentDescriptor, InterlockSpec, + ResourceSpec, SafetyClass, + valid_kind, ) from labwire.core.client import ( CommandHandle, @@ -27,6 +30,7 @@ TelemetrySubscription, ) from labwire.core.errors import ( + AuthorizationRequiredError, BusyError, CanceledError, ConfirmationRequiredError, @@ -39,33 +43,46 @@ LabwireError, MethodNotFoundError, NotCancelableError, + StaleRevisionError, + UnknownReferenceError, UnsupportedError, 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, CommandState, CommandStatus, EventNotification, EventSeverity, PeerInfo, Progress, + ResourceChangedData, + ResourceIndexEntry, + ResourceReadResult, + ResourceRevision, ServerCapabilities, ) from labwire.core.server import ( Clock, CommandContext, Instrument, + InstrumentResource, InstrumentServer, Interlock, + ResourceRef, + ResourceSnapshot, RunRecord, SystemClock, TelemetryChannel, channel, command, interlock, + resource, + unit_field, ) from labwire.core.session import JsonRpcSession, SessionClosed from labwire.core.signing import ( @@ -81,9 +98,12 @@ __all__ = [ "CONFIRMATION_REQUIRED_CLASSES", + "KIND_REGISTRY", "MANIFEST_VERSION", "MESSAGE_TYPES", "PROTOCOL_VERSION", + "Authorization", + "AuthorizationRequiredError", "BusyError", "CanceledError", "ChannelSpec", @@ -98,10 +118,14 @@ "EventNotification", "EventSeverity", "EventStream", + "Grant", + "GrantStore", + "GrantVerdict", "HardwareFaultError", "IdentityInfo", "Instrument", "InstrumentDescriptor", + "InstrumentResource", "InstrumentServer", "Interlock", "InterlockError", @@ -118,17 +142,26 @@ "NotCancelableError", "PeerInfo", "Progress", + "ResourceChangedData", + "ResourceIndexEntry", + "ResourceReadResult", + "ResourceRef", + "ResourceRevision", + "ResourceSnapshot", + "ResourceSpec", "RunRecord", "SafetyClass", "ServerCapabilities", "SessionClosed", "SigningKey", + "StaleRevisionError", "SystemClock", "TelemetryChannel", "TelemetrySample", "TelemetrySubscription", "Transport", "TransportClosed", + "UnknownReferenceError", "UnsupportedError", "ValidationError", "VerificationResult", @@ -140,7 +173,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 407115b..e1078aa 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,9 @@ def _walk( seen: frozenset[str], numeric: set[str], 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: @@ -181,50 +234,92 @@ def _walk( if _declares_number(resolved): numeric.add(path) + 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: + if keyword in resolved: + claimed.append((keyword, path, resolved)) structural = _CONSTRAINING_KEYS & resolved.keys() if not structural and not _declares_number(resolved): 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) + _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) + _walk( + member, + root, + path, + depth + 1, + seen, + numeric, + opaque, + units, + claimed, + branch_unit, + ) 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 +345,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: @@ -317,7 +424,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( @@ -436,6 +543,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): @@ -464,6 +637,83 @@ 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] + + @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). @@ -498,4 +748,36 @@ 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 + + @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 f957dd7..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, @@ -187,7 +188,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 @@ -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: @@ -366,7 +388,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/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 04302ec..bd5a876 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 @@ -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/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..2083d20 100644 --- a/packages/core/src/labwire/core/server.py +++ b/packages/core/src/labwire/core/server.py @@ -22,29 +22,32 @@ 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 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 ( - 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, ) @@ -325,7 +336,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: @@ -337,6 +348,240 @@ 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 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. + + 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 +735,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 +748,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 +777,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, ) @@ -547,7 +801,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 [ { @@ -599,6 +853,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 +873,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 +973,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 +1020,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 --------------------------------------------------------------- @@ -777,7 +1068,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"}) @@ -851,6 +1142,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,13 +1167,17 @@ 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") 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) @@ -895,9 +1192,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 §11.1: unsupported → validation → - # interlock → capacity busy. + # Rejection precedence per SPEC §12.1: unsupported → validation → + # 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 +1415,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 +1439,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,12 +1461,37 @@ 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). 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 @@ -1023,6 +1563,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 +1587,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 +1601,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) @@ -1068,7 +1652,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..7d438eb 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,15 +96,41 @@ class _M(BaseModel): class ManifestCommand(_M): - """The submitted command, verbatim, and its enforced class (SPEC §12.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): - """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 +138,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 +146,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 +154,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)) @@ -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 @@ -174,7 +202,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 +257,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_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_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_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 64e4e93..960549a 100644 --- a/packages/core/tests/test_server_protocol.py +++ b/packages/core/tests/test_server_protocol.py @@ -159,8 +159,16 @@ async def test_initialize_result_and_gating() -> None: "capabilities": {}, }, ) - assert result["protocol_version"] == "0.2" - assert result["capabilities"] == {"telemetry": True, "events": True, "manifests": False} + 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"] == { + "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_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_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..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( @@ -325,7 +407,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/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/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") 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/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/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/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 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 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" },