SDK guide

Writing an OpenMHS driver package

On this page
  1. 1. Layout
  2. 2. Workflow
  3. 3. The Driver API
  4. Choosing the shape
  5. Tag, register and PV devices (TagDriver)
  6. 4. Return, error and cancellation contract
  7. 5. Limits and provenance
  8. 6. Fakes and backends
  9. 7. The gate
  10. 8. Troubleshooting by gate id

One upstream repository becomes one driver package. The package is finished when this command exits 0:

sh
export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=MHS/sdk/src
python3 -m mhs.driver check MHS/sdk/examples/thermostat --backend fake

Run every command from the repository root. Replace MHS/sdk/examples/thermostat (the synthetic tutorial) with your driver_dir. The signatures are in ../INTERFACE.md, generated by python3 -m mhs.driver --print-interface.

1. Layout

MHS/drivers/<track>/<owner>__<repo>/, one directory per task card:

FileRule
task.jsonthe task card; input, never edited
driver.pyexactly one mhs.driver.Driver subclass (decorated, or a config-only family subclass)
fake.py and/or fixtures/the fake backend; fixtures/conformance.json holds conformance inputs (section 6)
native.pythe hardware backend factory; the gate never imports it
facts.mdtags and facts, each with source and path:LINE
test_driver.pycalls mhs.testing.check_driver(HERE, backend="fake"); add cases, never remove that call
driver.lock.jsonupstream pin, install, backends, authored_by, reviewed_by (null until a human signs)
reference.json, REFERENCE.mdgenerated by python3 -m mhs.driver reference <dir>; never hand-edited
site-limits.example.jsonoptional operator overlay; it may only narrow limits
RESULT.jsonpass, blocked or skip-proposed, with codes; CI re-runs the gate and never trusts it

The upstream clone (task.json local_path) is read-only. The gate parses it and never imports it. Only the tutorial has "upstream": null (its stand-in clone is vendor/). A package under MHS/drivers/ with upstream: null fails G2.

2. Workflow

sh
export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=MHS/sdk/src
python3 -m mhs.driver catalog search temperature setpoint     # find catalogue classes
python3 -m mhs.driver catalog show cap.thermal.setpoint.v1     # fields, units, limits, verb, risk floor
python3 -m mhs.driver catalog kinds                            # valid device_kind values
python3 -m mhs.driver reference MHS/sdk/examples/thermostat --check
python3 -m mhs.driver export MHS/sdk/examples/thermostat --out "$(mktemp -d)/mhs-adapter.json"

For a task card, the order is: python3 -m mhs.driver new <driver_dir> --card <driver_dir>/task.json --like <exemplar>, write fake.py, write driver.py, run python3 -m mhs.driver reference <driver_dir>, then run check until it exits 0. new without a card also works, for example python3 -m mhs.driver new "$(mktemp -d)/demo" --kind direct. The kinds are direct, python-object, serial, scpi, gymnasium and mujoco. Regenerate the reference after every change to driver.py (G5).

3. The Driver API

A complete driver, served in-process on its fake backend:

python
from mhs.driver import Driver, DriverFailed, Fact, Field, Limit, action, build_server, close_server, estop, read, write
from mhs.testing import client_for


class FakeBath:                      # in a package: fake.py, only methods the upstream class has (G7)
    def __init__(self, **_options):
        self.temp, self.setpoint, self.running = 21.0, 20.0, True

    def get_temperature(self):
        return self.temp

    def set_setpoint(self, value):
        self.setpoint = value

    def start_ramp(self, target):
        return -3 if target > 70 else 0  # the native SDK reports errors as return codes

    def stop(self):
        self.running = False


class Bath(Driver):
    device_id, device_kind = "demo-bath", "thermal.zone"
    mode, risk = "hardware", "R2"
    native = "demo_sdk.bath:Bath"        # the upstream class: parsed by the gate, never imported
    backends = {"hardware": ".native:connect", "fake": FakeBath}   # a package uses ".fake:FakeBath"
    facts = [Fact("Setpoint range 5 to 80 Cel", source="vendor_doc", ref="MANUAL.md:9")]
    safe_state = "heater off, circulation stopped"
    estop_physical_ref = "MANUAL.md:15"  # R2+ hardware: an independent hardware e-stop (SAFE-045)

    @read("temperature", unit="Cel", description="Bath temperature")
    def temperature(self):
        return self.native.get_temperature()

    @write("setpoint", unit="Cel", description="Heater setpoint",
           limit=Limit(min=5, max=80, source="vendor_doc", ref="MANUAL.md:9"))
    def set_setpoint(self, value):
        self.native.set_setpoint(value)

    @action(cap="cap.thermal.setpoint.v1", description="Ramp to a target temperature",
            input={"target_temperature": Field(description="Target temperature",
                                               limit=Limit(min=5, max=80, source="vendor_doc", ref="MANUAL.md:9"))})
    def ramp(self, target_temperature):
        code = self.native.start_ramp(target_temperature)
        if code != 0:
            raise DriverFailed("MHS.DEVICE.FAULTED", f"start_ramp returned {code}", native_code=code)
        return {"setpoint": target_temperature}

    @estop
    def stop(self):
        self.native.stop()


server = build_server(Bath(backend="fake"))     # strict; a fake backend is served as mode "simulated"
client = client_for(server)
device_id = client.hello()["devices"][0]["device_id"]          # mhs://localhost/demo-bath/fake
session = client.open_session(device_ids=[device_id])["session_id"]


def ramp(target):
    return client.invoke("cap.thermal.setpoint.v1", {"target_temperature": target},
                         device_id=device_id, session_id=session)


assert ramp(40)["status"] == "completed"
assert ramp(95)["error"]["mhs_code"] == "MHS.SAFETY.ENVELOPE_VIOLATION"   # refused, no native call
assert ramp(75)["status"] == "failed"                                      # DriverFailed
client.estop("demo", device_id=device_id, session_id=session)
assert ramp(40)["error"]["mhs_code"] == "MHS.SAFETY.ESTOP_ENGAGED"         # latched until an operator reset
close_server(server)

Class attributes: device_id (short name; served as mhs://localhost/<device_id>), device_kind (from catalog kinds), mode (hardware or simulated), risk (R0..R3; never below the card's risk_floor or a class floor), native ("package.module:Class"), backends, tags, facts, safe_state (one sentence), estop_physical_ref, estop_latency_ms, estop_terminal (one sentence; see "Recovery after an e-stop" below), watchdog_ms, vendor, model, display_name, resource_id (default main), fidelity (simulated drivers only), extra_resources (package composition, below).

Package composition. A device whose outputs need classes of different resource kinds (a stable image binds sensor.camera, a stable point cloud binds sensor.depth) declares further typed resources of the same device: extra_resources = {"pointcloud": {"kind": "sensor.depth", "title": "..."}}, and binds an action to one with @action(cap, resource="pointcloud") or, in RosDriver, actions[cap] = Action(resource="pointcloud"). Each binding resolves against its own resource's kind; everything else stays one device: one backend object and bridge, one e-stop latch, one session, one lifecycle. The rules are checked before any backend exists (gate G3 DEFINITION): at most 7 extra resources, ids like resource_id and different from it, catalogue kinds, every extra resource bound by at least one action, an action's resource naming a declared resource, and each capability still bound once. An extra resource has no parameters: it reads as {} without native I/O and refuses writes. Only decorated Driver classes and RosDriver accept extra_resources; in RosDriver a command map's own resource key is not authority and must equal the declared binding resource. Do not use composition to dodge a kind mismatch: the resource must really be what its kind says. Several drivers or several devices in one package are not supported.

DecoratorMethodServed as
@read(name, unit=, description=, type=)def get(self) returns the valueread-only state, mhs.resource.read
@write(name, unit=, limit=, description=, risk=)def set_x(self, value)writable state, cap.device.configure.v1
@action(cap="cap.<class>.v1", input={...})parameters named like the class fieldsa catalogue class
@action(name="x-cap.<owner>.<name>.v1", input=, output=)every input needs a Fieldan extension with an inline schema
@estopdef stop(self)the native stop; the runtime latches first, then calls it
@interlock(id, description=, blocks=[cap ids])returns True when closed (safe)anything else, or an exception, is unknown (blocks like open)
@resetoptional recovery that changes nothing on the deviceruns after an accepted mhs.safety.reset; refused beside a reenable=True action
  • @action also takes long_running=True with max_duration_ms, cancellable, risk, examples, safe_state=True and reenable=True. A safe-state action takes no input and runs during an e-stop. With cap=, a Field(...) in input= refines the class field (description, limit, native_unit); it never changes its type or unit.
  • Recovery after an e-stop. A reset releases the latch and never re-arms or resumes anything (SAFE-042). An @reset hook may only re-establish communication that changes nothing on the device (reconnect a socket, re-attach a client object, reopen a handle); anything that may energise the device, enable motion or start a process that may initialise hardware is never a reset step. When the upstream stop leaves the device disabled and the upstream has its own command that enables it again (a drive's switch_on, an interface's start()), bind that command and mark its action reenable=True (a family driver: Action(..., reenable=True)): one action, no input (the empty object must validate against its input schema), never a safe-state action, a stop or a read. Put every native recovery step the device needs after a stop, fault clearing included, in that action: a decorated driver that declares a re-enable has no @reset hook (the SDK refuses one at definition and at start-up, and gate G3 fails). A family driver's safety_bindings.reset on the re-enable's native target is a definition error; one that re-arms through another target is not detected, so do not write it. The runtime refuses the re-enable while latched and the reset receipt names it. On such a device an e-stop that lands while any effectful command is in flight (the re-enable, or a move whose upstream cancel path re-arms the device; reads are not waited for) is confirmed only by a stop that runs after those commands settled. Conformance test TSAFE-001 sends the re-enable after the reset and before its recovery command (CORE-086). When the upstream stop cannot be undone without a new native session (a close() after which the upstream requires a new initialisation that may change the device), set estop_terminal = "<why, citing the upstream line>": the runtime then refuses every reset after an e-stop with terminal_estop and keeps the latch, the next endpoint starts a new session, and TSAFE-001 checks that refusal instead of a recovery (CORE-087). Never reopen or reinitialise a device in @reset to make a reset look successful.
  • Field(type, unit=, description=, limit=, items=, length=, properties=, enum=, native_unit=, delta=). length fixes an array's length (per-element limits need it). delta=True marks a temperature difference.
  • Limit(min=, max=, allowed=, names=, path=, unit=, source=, ref=, reason=), or Limit.operator_required(reason). Fact(text, source=, ref=).
  • Methods may be def or async def. Use self.native (the backend object), self.options, self.ctx, self.cancelled() and self.check_cancelled(). Optional hooks are setup(self) and teardown(self).
  • A subclass that overrides an @estop method must decorate the override again. Otherwise it has no native stop.
  • Family drivers (PythonObjectDriver, SerialDriver, ScpiDriver, GymnasiumDriver, MujocoDriver, RosDriver) set class attributes only (parameters, reads, writes, commands, actions with Action(...) risks R0..R3, safety_bindings). bridges.md gives their grammar; ros-driver.md specifies ROS bindings, source references, recording fakes and the hardware-review requirement at every risk level. <!-- exemplars-pyobj (2026-11-04) -->
  • QcodesDriver and OphydDriver are config-only Python-object families whose fields are explicit parameter_bindings (QCoDeS Parameters) or signal_bindings (classic Ophyd Components), each cited by source_ref and proven by G7 in the pinned source. qcodes-driver.md and ophyd-driver.md specify them; add_parameter, nested or ophyd-async Devices and custom Signal classes are not expressible (stop blocked with the G7 finding). <!-- end exemplars-pyobj -->
  • McpClientDriver is config-only too: mcp_config (the pinned source file and one entry per selected MCP tool), commands, actions and reads. mcp-client.md gives the contract; the family exemplar is MHS/drivers/labs/mattjax16__rigol-dho-mcp. Every non-fake backend refuses, so such a package is fake-only.
  • Sila2Driver is config-only as well: sila2_config (the pinned FDL, proto and implementation with their SHA-256 and the complete member table, usually in declarations.py), commands, actions, reads and the feature's Stop in safety_bindings. sila2-controlled-profile.md gives the contract; the family exemplar is MHS/drivers/labs/anguyen-23__sila-testing. Its source proof accepts only the PhenixImager feature at its pinned commit, and every non-fake backend refuses, so such a package is fake-only.
  • PyLabRobotDriver sets one plr_profile = PlrProfile(...) (plus read-only parameters/reads); its operations, limits, stop policy, fake model (mhs.testing.pylabrobot) and G7 source checks are in pylabrobot-helper.md. Its stop() is teardown, never the e-stop.

Choosing the shape

For a function-only upstream or a Python filename that cannot be imported as a module, use an explicit source-only selector such as native = "pyfile:spectrometer.py#*" or native = "pyfile:2230G.py#KEI2230G". Explicit Python source selectors describes the exact card/file/line contract, module signature checks and deliberate limitations. These values are evidence metadata; backends still constructs the runtime object.

The task card's decision and helper come from a static scan; they are a first guess. Check them against the upstream code before writing anything.

The upstream interface isWrite
an object with methods (a vendor SDK class, a robot API)a direct Driver, or PythonObjectDriver for a plain object
a C++ client class that the upstream exposes to Python only through pybind11 bindingsa direct Driver with native = "pybind:<binding.cpp>#<scope>.<Class>" (source-selectors.md)
text lines the device parses, each command word written out in the code or the manual (MEAS:VOLT?, SLVL 0)ScpiDriver or SerialDriver with a commands table
bytes built from parts: a command letter plus a number (V5R), a frame with address, sequence and checksum, a binary protocola direct Driver over the upstream class, with a native.py that opens the upstream transport (MHS/sdk/examples/thermostat, the julabo exemplar)
a Gymnasium environment, a MuJoCo modelGymnasiumDriver, MujocoDriver
ROS 1/2 topics, services and actions exposed through rosbridgeRosDriver; cite endpoint/type evidence and select the explicit action profile, without inventing a Python native class; a publication that a separate step service applies is one publish-then-step command (ros-driver.md)
an MCP server whose tools are literal @<receiver>.tool() functions of one file, for an R0 or R1 deviceMcpClientDriver with one mcp_config tool entry and one command per tool (mcp-client.md)
a PyLabRobot LiquidHandler front end (directly or held by an upstream object)PyLabRobotDriver with one PlrProfile: self-contained transfer/mix and a tracked_volumes read (pylabrobot-helper.md)
the PhenixImager SiLA 2 feature at its pinned commit (the one feature Sila2Driver proves today)Sila2Driver with the complete feature declaration (sila2-controlled-profile.md); any other SiLA feature: a direct Driver over the repository's own client class, or blocked HELPER_MISSING

Simulator families may declare fidelity_by_backend = {"fake": {...}, "sim": {...}} when the recording or analytic fake and the real simulator have different fidelity. Entries override the derived defaults and then the common fidelity mapping for that backend; an omitted entry retains existing behavior. Keep engine, timing, determinism and gaps accurate for the backend actually opened. A native class name documents provenance; it does not create a simulator factory. Use a real backends["sim"] factory, a supported environment registration, or an explicitly supplied environment. ROS live simulation instead uses the operator's explicit deployment configuration.

A command table cannot reproduce bytes the upstream assembles at run time, and gate G7 cannot find them in the clone: it takes the text before the first space, ?, { or ( of every line the fake received and searches the clone for it. Do not change the wire syntax (adding a space, splitting a token) to get through G7: the fake would then answer something the device never receives. Switching a family-config card to a direct driver is allowed; say so in RESULT.json notes ("DEVIATION FROM THE CARD") so a maintainer can correct the card with an override. collect.py prints it as DECISION_DEVIATION. If neither shape can express the device, stop with HELPER_MISSING.

<!-- tag-bridge helper (2026-11-04): begin -->

Tag, register and PV devices (TagDriver)

A device that a server exposes by register number, PV name or Alpaca member (Modbus, EPICS Channel Access, ASCOM Alpaca) is a config-only TagDriver: a tag_config with a profile and a finite points table (every address with source_ref of the line that shows it in its role, such as the register argument of the Modbus read or write call), plus parameters, reads, writes, commands, actions and safety_bindings. Any write or effectful command needs safety_bindings["estop"] with fixed steps and a confirmation read; R2+ hardware also needs an input-free Action(safe_state=True). A device without a real stop route may only observe. Values the register codec cannot represent exactly are refused, never rounded. The fake subclasses mhs.bridge.tag_fake.RecordingTagTransport; native.py builds the vendor client at hardware time, and every TagDriver hardware backend needs a human reviewed_by of scope hardware first (all risk classes). If the tag protocol is only a service layer over a serial or SDK device, use that device's shape instead. Grammar, transport interface and runtime rules: tag-bridge.md; exemplar: MHS/drivers/labs/josuablejeru__labmastro. <!-- tag-bridge helper (2026-11-04): end -->

4. Return, error and cancellation contract

  • An action returns a dict that matches its output schema, a scalar (wrapped into the class's single output field), or None. A required sampled_at is stamped. A getter returns the value. A setter returns nothing, or the value actually applied. Never return {"output": ..., "state": ...} and never put an error in an output field.
  • Report every outcome that is not a success by raising:
RaiseReceipt
DriverFailed(code, message, native_code=)failed with code (use mhs.errors.DRIVER_SUBSET, e.g. MHS.DEVICE.FAULTED, MHS.DEVICE.PRECONDITION_UNMET)
Cancelled(message)cancelled (check self.cancelled() in long loops)
Busy(message)rejected MHS.DEVICE.BUSY (nothing actuated; retryable)
OutcomeUnknown(message)unknown MHS.OUTCOME.UNKNOWN (the effect may have happened; not retryable)
any other exceptionfailed MHS.ADAPTER.SHIM_FAILED, message redacted
  • Never catch a failing native call and carry on (G6 FAILURE_HIDDEN).
  • @estop may run while an action is running, from another thread. It must not wait for that action or take a lock the action holds. Async calls are bounded by the request deadline (or max_duration_ms for long-running actions) and cancelled on an e-stop.
  • A safe_state=True action runs inside the e-stop too, after the @estop method, so a silent device must not hold it. When the upstream method it calls waits for a reply with a long default (a wait_for of 60 minutes), send the command through the native send path or pass a short timeout, and say so in facts.md.
  • The runtime owns every safety rule: limits, interlocks, the latch, permits, receipts and revisions. A driver translates. It never clamps a value, retries an actuation or decides that a refusal is fine.
  • An actuation must leave an observable effect, in its output or in the resource state. Otherwise conformance test TCAP-003 fails G8.

5. Limits and provenance

  • Declare a limit once, on the field it bounds. A bare min/max on an array bounds every element. min=[...], max=[...] with names=[...] and Field(length=N) is per element. path="a.b", "a[*]" or "a[0]" reaches nested leaves.
  • source is vendor_doc or upstream_code, and ref is "path/in/clone:LINE" at the pinned commit (G4 checks that the line exists). A file in a git submodule of the clone is cited by its path from the clone root (third_party/lib/file.py:12): the gate reads it at the commit the pinned tree's gitlink records, from the submodule's own object store, only when the pinned .gitmodules declares it and its checkout is at that commit (absent, empty or at another commit: REF_MISSING with the reason), and names each such submodule in the warning G1.SUBMODULE_SOURCE. A card that declares companion sources (companions) also lets facts, a ROS stop, its read-back, the declared re-enable and the bridges the operator must run (operator_bridges) cite "companion:<alias>/path:LINE" at the companion's pin; a bound never does (ros-driver.md, "Companion sources"; a companion fetched under work/companions carries lock, and tools/fetch_companions.py puts it back in an export). Never invent a bound. If no source states it, use Limit.operator_required("why"): every call that sets the field is refused with MHS.SAFETY.LIMITS_UNSET until the operator's site-limits.json supplies it.
  • An operator bound never goes in driver.py (G3 LIMIT_SOURCE). It goes in site-limits.json; see MHS/sdk/examples/thermostat/site-limits.example.json. The overlay narrows only. A widening overlay is refused at load. Its keys are the device id (mhs://localhost/<device_id>), then the capability id and, under it, the limit path of an action input ("x-cap.owner.dispense.v1": {"volume": {...}}), or directly the name of a writable state field ("setpoint": {...}); MHS/schema/site-limits.schema.json is the grammar.
  • A ref points into a text file. A vendor manual that exists only as a PDF is cited docs/manual.pdf:PAGE (the number after the colon is the page): G4 reports REF_BINARY because it cannot read the page, collect.py lists the citation as UNVERIFIABLE_CITATION, and the reviewer opens it. Prefer a text file that states the same thing, and never cite a page you did not read.
  • A bound can sit on a different line than the code that uses it (a constructor default such as num_ports=9, a docstring [5-6000]). G4 reads a range written as 5-6000 or [5-6000] as both ends, but warns (REF_VALUE) when the cited line shows no such number. Cite the line that holds the number. A bound that depends on a constructor option (a port count, a syringe size) is a default of the library, not a bound of the device: use Limit.operator_required and say why in its reason.
  • Hardware or hybrid R1+ drivers limit every numeric write or invoke input, or mark it operator_required (G3 LIMITS_UNSET). Leave catalogue inputs you cannot bound out of the method signature.
  • A GymnasiumDriver declares no Limit for its action: the bridge derives the cap.world.step.v1 bounds from the action space of the environment it serves. When gymnasium.make(env_id) built that environment, they are upstream_code with ref <env_id>:action_space. When a backends entry built it (the package's fake), they carry no source, and their ref names the backend (action_space of the fake backend .fake:FakeX); REFERENCE.md prints it after no source:. If the fake copies the upstream's space, set action_space_ref = "path/in/clone:LINE" to the line that defines it (the Box(...) in the environment's __init__, say; a Discrete(n) shows its largest action n - 1, and Box(-np.ones(4), np.ones(4)) shows -1 and 1). The derived bounds are then upstream_code with that ref, and G4 checks the line as it checks the ref of a Limit (REF_VALUE when the line does not show the bounds). When the Box takes its bounds straight from names, cite the line that writes their numbers (self.min_action, self.max_action = -0.25, 0.25). A short cited span can also contain high = np.ones(n) and Box(-high, high), including one literal scale defined in that same span (self.max_torque = 1000, then high = np.ones(n) * self.max_torque). G4 uses a bounded source-only AST reader for those straight-line forms; it rejects control flow, calls outside the approved ones/Box forms, reassignment, aliases and mutation. Shape names are opaque: this proves bound values, not dimensions. Include the complete definitions and Box, using path:START-END; an unresolved observation Box in between contributes no bounds. Out-of-span scales/defaults, unsupported dtypes or expressions still get no inferred value, so leave the ref unset when no citation shows the actual bounds. Never set it for a placeholder space or infer runtime compatibility from a citation.
  • A MujocoDriver declares no Limit for its action either: the bridge derives action.ctrl from the model's actuator_ctrlrange (upstream_code, ref <model>:actuator_ctrlrange). An actuator the model gives no control range is never published as unbounded: it is operator_required (the whole action.ctrl when no actuator has a range, else action.ctrl[N] per element, next to the model's ranges), so the endpoint refuses a step that sets it (MHS.SAFETY.LIMITS_UNSET, before the native step) until the operator's site-limits.json bounds it: action.ctrl (a scalar, or one value per element) for the first case, one action.ctrl[N] entry per listed element for the second; an overlay may only narrow a model range. Ship a site-limits.example.json with such entries (operator data: "source": "operator", a reason that starts example only:) so the gate's conformance run can step the model. The fake reports the model's actuators as MuJoCo compiles them: never hide an actuator without a range, and never give it a range the model does not state (a joint range is not a control range). When a source states a bound for a control (an upstream clamp of the command), declare it with a cited Limit on the step action, actions = {"cap.world.step.v1": Action(name="step", limits={"action.ctrl[7]": Limit(...)})} (or on the whole action.ctrl): a declared rule takes precedence, and the bridge derives only the controls no declared rule bounds on both sides. A declared rule may only narrow a model range; a wider one, Limit.operator_required on a ranged actuator (or on the whole action.ctrl of a model that ranges every actuator), or a one-sided element rule that leaves no free path for the other side does not resolve (G3).
  • A model-authored driver writes only vendor_doc and upstream_code facts and limits. watchdog_ms needs a Fact that cites the device's own watchdog: the runtime implements no watchdog.
  • Hardware R2+ needs estop_physical_ref (path:LINE of an independent hardware e-stop) or an @action(..., safe_state=True) (SAFE-045). A model-authored hardware R2+ driver never runs its hardware backend until a human sets reviewed_by in driver.lock.json.

6. Fakes and backends

  • Backend names are hardware, fake and sim, and nothing else. fake and sim may not be the hardware entry point, may not use the .native module, and may not be or derive from the native class (G3 FAKE_IS_NATIVE).
  • A fake lives in the package (.fake:FakeX). The only exception is an upstream simulator, when the card's mock_strategy.preferred is upstream-sim (otherwise G3 FAKE_OUTSIDE_PACKAGE). Prefer, in order: the upstream simulator, a recording fake, a transcript.
  • A fake implements only attributes the upstream native class has (G7 reads the clone's AST). Serial and SCPI fakes answer only command mnemonics that occur in the clone. Keep state in plain attributes, make the native stop observable (G6 records it), and give each device its own fake object.
  • A fake backend serves the device as mode simulated, with id <device id>/fake and fidelity F0. Its evidence is contract_checked, never an MHS-Device level.
  • The runtime reads every @read before and after each action, so a fake sees each read method called twice per action (and once after an e-stop); count calls with a filter, not with an exact number. An e-stop calls the @estop method and then every safe_state=True action: if both reach one native method, the fake sees it twice (and so does the device: the runtime cannot see inside a method, so a stop that must not arrive twice, a toggle or a LAND that re-arms motors, is not an acceptable stop for both).
  • Numbers in a fake (start positions, canned replies) are test data, not facts: mark each # placeholder, and never copy one into a Fact, a Limit or the reference. The one exception is the action space of a Gymnasium fake: the reference shows the bounds derived from it, marked as the fake's (section 5); when they are placeholders, say so in RESULT.json notes and the fidelity gaps. A fake may return a smaller array than the device does (an image, a scan) to keep receipts small and the gate under its time bound (a 480 x 640 x 3 stand-in cost 3.4 MB and 3.7 s per step); the real size goes into RESULT.json notes and, for a simulator, into the fidelity gaps. If the large data is what the device is for, publish it as an artifact (image-artifacts.md; a file the native writes to disk: file-artifacts.md) or stop with NEEDS_ARTIFACTS.
  • mhs.driver new --like copies every non-generated file of the exemplar (native.py, fixtures/, site-limits.example.json) and writes driver.lock.json for the template kind of the card. Rewrite or delete what it copied, and set the lock's backends to your class's (G2 BACKENDS_DIFFER prints the value).
  • fixtures/conformance.json supplies inputs that synthesis cannot build, for example strings with a pattern or allowed sets:
json
{"capabilities": {"cap.thermal.setpoint.v1": {"valid": {"target_temperature": 37},
  "alternate": {"target_temperature": 38}, "out_of_envelope": [{"target_temperature": 81}]}}}
  • Package code must not exit the interpreter, read sys.argv, import mhs.server, mhs.mcp, mhs.conformance or the gate, patch mhs, or reach into sys.modules, frames or builtins (G3 FORBIDDEN). This includes test_driver.py. Use mhs.adapter.load and mhs.testing instead. It also never names a private member of the endpoint's served-mode binding or of the bridge's safe-state commit (_bind_served_mode, _grant_served_mode, _bind_sensed_route, _served_mode, _mhs_served_mode, _commit_safe_state_locked, _safe_state_achieved, _safe_stamp; as an attribute, a name, a string or a constant expression): only the server binds what it serves, so a package cannot grant itself a sensed confirmation (SAFE-049).

7. The gate

python3 -m mhs.driver check <dir> --backend fake exits 0 (pass), 1 (fail; prints the failing gate ids) or 2 (usage). It writes check-report.json. It runs the gates in a child process with the network and device files disabled, and never imports the package itself.

GateChecks
G1required files; task.json names this directory (and equals the registry card; a card that declares companions must have one); no symlink leaves the package; the clone exists and stays unmodified
G2driver.lock.json validates and pins the card's package id, commit, repository and licence
G3import and strict resolution against the catalogue; card mode and risk floor; hardware R1+ has @estop, safe_state and limits; every Limit has a source and ref; the fake is not the native class; no forbidden code
G4every ref path:LINE (and a GymnasiumDriver's action_space_ref) exists in the clone at the pinned commit (a path into a git submodule at the commit its gitlink records)
G5reference.json and REFERENCE.md equal a fresh reference run
G6behaviour on fresh endpoints: reads; writes reach the fake; every bound, array element and nested leaf is refused ENVELOPE_VIOLATION with zero native calls; an e-stop cancels a running action and reaches the native stop; latched refusals; agent reset refused on hardware; native failure gives failed; operator_required gives LIMITS_UNSET; outputs validate
G7attributes of the fake, and those the driver touched, exist on native in the clone's AST; serial/SCPI: the first token of every line the fake received (up to a space, ?, { or (; ; separates commands) occurs in the clone; a transport family's native (serial, SCPI, ROS, HTTP, SiLA 2, MCP client) exists in the clone
G8in-process conformance MHS-Sim CL3; the MCP projection builds; inputs have a description (numbers a unit); tools are at most 2048 bytes
G9test_driver.py keeps the check_driver call and passes within 180 s without network

8. Troubleshooting by gate id

Read the failing finding, <gate>.<CODE>, in the gate output or in check-report.json.

FindingFix
G1.MISSING_FILE, G1.MISSING_FAKEadd the file; a hardware driver needs fake.py or fixtures/
G1.CARD_MISMATCH, G1.CARD_EDITED, G1.CARD_INVALIDrestore task.json from the registry card; never edit it
G1.COMPANION_CARDcompanions come only from a reviewed registry override through the registry card: restore task.json from the card and run the gate in the project tree (ros-driver.md, "Companion sources")
G1.COMPANION_LOCK, G1.COMPANION_WRITABLE, G1.NO_COMPANION_CLONEa fetched (lock) companion must match its work/companions/LOCK.json entry, stay read-only and exist: fetch it with python3 tools/fetch_companions.py <dir> (offline first) or pass --companions-root DIR; never write in it
G1.NO_CLONE, G1.CLONE_MODIFIED, G1.CLONE_WRITTENthe clone must exist at local_path and stay untouched; nothing may write into it
G1.SYMLINK_OUTSIDE, G1.QUARANTINEremove the symlink / stop: write RESULT.json blocked QUARANTINE
G1.SUBMODULE_SOURCE (warning)informative: a citation or the native lies in a git submodule, read at its gitlink commit; that repository's licence is its own (name it in the notes)
G2.NO_LOCK, G2.LOCK_INVALID, G2.PACKAGE_MISMATCH, G2.CARD_UPSTREAMthe lock pins the card's package id, repository, commit and licence (new --card writes it)
G2.COMMIT_ABSENTthe clone must contain the card's pinned commit
G2.BACKENDS_DIFFERdriver.lock.json backends must equal the class's backends; the message prints the value (new writes the entries of its template kind)
G2.FILE_CHANGED, G2.SDK_VERSION (warnings)the lock was written for other file contents or another SDK version; informative
G2.NO_UPSTREAM, G2.SYNTHETIC_LOCKonly the tutorial has upstream: null
G3.IMPORT, G3.DEFINITIONfix driver.py; the message lists every definition problem
G3.STRICTa drifting class field, unknown class or kind, or bad limit path: run catalog show <cap> and use the class field names and units. SAFE-045 (hardware R2+): set estop_physical_ref = "path:LINE" of an independent hardware stop in the vendor text, or declare an @action(..., safe_state=True) (a family driver: an Action(..., safe_state=True) in actions with a command of the same id in commands; safety_bindings.safe_state alone does not count); a safe_state sentence or a software latch alone is not enough
G3.MODE_MISMATCHbuild the card's safety.expected_mode; a hardware card needs a hardware driver with a fake backend
G3.RISK_FLOOR, G3.UNKNOWN_KINDraise risk to the card's floor; pick a kind from catalog kinds
G3.NO_ESTOP, G3.NO_SAFE_STATEhardware R1+: add @estop (or safety_bindings["estop"]) and a safe_state sentence
G3.LIMITS_UNSETgive the numeric input a sourced Limit, Limit.operator_required(...), or drop it from the signature
G3.UNSOURCED_LIMIT, G3.LIMIT_SOURCE, G3.FACT_SOURCEcite vendor_doc/upstream_code with ref; operator bounds go to site-limits.json
G3.FAKE_IS_NATIVE, G3.FAKE_OUTSIDE_PACKAGEwrite a package fake; it must not be, derive from or re-export native (an upstream simulator needs mock_strategy.preferred: upstream-sim)
G3.BACKEND_IMPORTthe backends entry cannot be imported; fix the .fake:Name path
G3.FORBIDDEN, G3.SYNTAXremove the construct named in the message (section 6)
G3.GATE_TAMPEREDpackage code changed an mhs module; remove it
<gate>.ABORTEDpackage code exited the gate's process, or the report is not the gate's own; remove exits and sys.argv use
<gate>.TIMEOUTa call hangs (900 s gate bound); bound native waits; RESULT.json blocked GATE_TIMEOUT if it cannot be fixed
G4.REF_FORMAT, G4.REF_MISSING, G4.REF_LINEcite path/in/clone:LINE of a file and line that exist at the pinned commit; inside a git submodule the message says why it was not read (not declared, absent, not checked out, or checked out at another commit than the gitlink)
G4.REF_VALUE (warning)the cited line does not show the bound: cite the line that does, or explain in the notes; a reviewer reads every one
G4.REF_BINARY (warning)the cited file is not text (a PDF): docs/manual.pdf:PAGE; a reviewer opens the page
<gate>.NOT_RUN (warning)the gate was skipped because an earlier one failed (G5, G6, G8 and G9 after a G3 failure); fix the earlier finding first
G5.MISSING, G5.STALEpython3 -m mhs.driver reference <dir>
G6.ENVELOPE_NOT_ENFORCED, G6.ENVELOPE_INVALIDdeclare the limit on the field the runtime checks; do not validate bounds in the driver
G6.ENVELOPE_GAPno out-of-limit input can be built: add out_of_envelope to fixtures/conformance.json
G6.ESTOP_NO_NATIVE, G6.ESTOP_FAILED, G6.ESTOP_SLOW@estop must call the native stop through self.native, return promptly and not wait for the running action; ordinary nested Python attribute objects are recorded too, including self.native.motor.stop() and nested assignments
G6.ESTOP_NOT_CANCELLEDlong actions check self.cancelled() or are async
G6.LATCH_NOT_ENFORCED, G6.AGENT_RESET_ACCEPTEDnever override call, estop or reset; use the decorators
G6.FAILURE_HIDDENlet the first failing native call raise (or raise DriverFailed); never swallow it
G6.NO_NATIVE_CALL, G6.NOT_COMPLETED, G6.OUTPUT_INVALID, G6.READ_*writes must reach the native; outputs and state must match their schemas
G6.OPERATOR_REQUIRED_*, G6.OVERLAY_INVALIDkeep operator_required paths valid; site-limits.example.json may only narrow
G7.NOT_UPSTREAMthe fake or driver uses a name that is not on the upstream class or in the clone; remove it
G7.COMMAND_NOT_UPSTREAMthe command's first token is not in the clone. A misspelt command: fix it. A command the upstream assembles from parts (V5R) or a framed protocol: write a direct Driver ("Choosing the shape"); never alter the syntax to pass
G7.NOT_AN_ENTRY_SYMBOLnative must be the card's entry symbol (an error when the card's symbol was confirmed by ast or by a maintainer's override)
G7.MAYBE_ABSENT (warning)the class has an external base, __getattr__ or a helper that sets attributes on it (setattr(self._owner, name, ...) in a class it hands itself to); the name occurs in the clone, or it is part of the Gymnasium environment interface (action_space, reset, ...) of a GymnasiumDriver whose base lies outside the clone, so it is only a warning
G7.LEGACY_SOURCE (warning)the native class is Python 2 source: G7 read it with the lines Python 3 rejects left out (a member defined only on such a line is not seen); the clone cannot be imported under Python 3, so native.py ports it

Nested recording is deliberately limited to ordinary Python attribute objects. Arrays, containers, value objects and method return values keep their native behavior; item mutations and calls made internally by native code are not traced. Child proxies preserve the accessed path and share the root log's phases and failure injection, but not concrete type or is checks. Property getters still execute before lookup is recorded. G7 currently checks the root native class's members; the presence of a recorded nested stop does not establish that child's pinned source behavior or physical effectiveness. | G7.NATIVE_NOT_FOUND, G7.NO_NATIVE | set native = "package.module:Class" as it exists in the clone; a serial, SCPI, ROS, HTTP, SiLA 2 or MCP-client native may also name a module-level function or a name the module binds when it runs (keithley2400:open_connection; not a bare annotation, an augmented assignment, a deleted name or one bound only under if False: or if TYPE_CHECKING:), or be unset for a ROS graph; a class that exists only as pybind11 binding source is pybind:<binding.cpp>#<scope>.<Class> (source-selectors.md) | | G7.PLR_REF, G7.PLR_SOURCE | a plr_profile reference names no line of the clone, or the cited span does not show what it claims (the handler, a labware name, set_tip_tracking(True), every PyLabRobot call of an operation); cite the line that does (pylabrobot-helper.md) | | G8.CONFORMANCE, G8.TEST | read the listed test ids; SAFE-014 skipped on a pattern/format or allowed-set input needs a fixtures/conformance.json entry; TCAP-003 wants an observable effect: a write must change a state field that a @read returns (declare the read of the same field), and an x-cap action must return an output= or change such a field | | G8.NO_DESCRIPTION, G8.NO_UNIT | Field(description=..., unit=...) on every input; "1" for a count | | G8.TOOL_SIZE, G8.TOOL_DESCRIPTION | shorten descriptions; each tool stays within 2048 bytes | | G8.MCP | the projection cannot list tools; fix the manifest error in the message | | G9.NO_HARNESS, G9.FAILED, G9.NO_RESULT, G9.TIMEOUT | keep the check_driver(HERE, backend="fake") test; no exits, no network, under 180 s |

Residual risk: the gate evaluates package code in its child process. Code written to defeat its checks is not ruled out mechanically, so CI re-runs the gate and a human reviews every driver before hardware use.