SDK guide
Writing an OpenMHS driver package
On this page
One upstream repository becomes one driver package. The package is finished when this command exits 0:
export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=MHS/sdk/src
python3 -m mhs.driver check MHS/sdk/examples/thermostat --backend fakeRun 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:
| File | Rule |
|---|---|
task.json | the task card; input, never edited |
driver.py | exactly 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.py | the hardware backend factory; the gate never imports it |
facts.md | tags and facts, each with source and path:LINE |
test_driver.py | calls mhs.testing.check_driver(HERE, backend="fake"); add cases, never remove that call |
driver.lock.json | upstream pin, install, backends, authored_by, reviewed_by (null until a human signs) |
reference.json, REFERENCE.md | generated by python3 -m mhs.driver reference <dir>; never hand-edited |
site-limits.example.json | optional operator overlay; it may only narrow limits |
RESULT.json | pass, 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
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:
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.
| Decorator | Method | Served as |
|---|---|---|
@read(name, unit=, description=, type=) | def get(self) returns the value | read-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 fields | a catalogue class |
@action(name="x-cap.<owner>.<name>.v1", input=, output=) | every input needs a Field | an extension with an inline schema |
@estop | def 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) |
@reset | optional recovery that changes nothing on the device | runs after an accepted mhs.safety.reset; refused beside a reenable=True action |
@actionalso takeslong_running=Truewithmax_duration_ms,cancellable,risk,examples,safe_state=Trueandreenable=True. A safe-state action takes no input and runs during an e-stop. Withcap=, aField(...)ininput=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
@resethook 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'sswitch_on, an interface'sstart()), bind that command and mark its actionreenable=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@resethook (the SDK refuses one at definition and at start-up, and gate G3 fails). A family driver'ssafety_bindings.reseton 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 testTSAFE-001sends 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 (aclose()after which the upstream requires a new initialisation that may change the device), setestop_terminal = "<why, citing the upstream line>": the runtime then refuses every reset after an e-stop withterminal_estopand keeps the latch, the next endpoint starts a new session, andTSAFE-001checks that refusal instead of a recovery (CORE-087). Never reopen or reinitialise a device in@resetto make a reset look successful. Field(type, unit=, description=, limit=, items=, length=, properties=, enum=, native_unit=, delta=).lengthfixes an array's length (per-element limits need it).delta=Truemarks a temperature difference.Limit(min=, max=, allowed=, names=, path=, unit=, source=, ref=, reason=), orLimit.operator_required(reason).Fact(text, source=, ref=).- Methods may be
deforasync def. Useself.native(the backend object),self.options,self.ctx,self.cancelled()andself.check_cancelled(). Optional hooks aresetup(self)andteardown(self). - A subclass that overrides an
@estopmethod 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,actionswithAction(...)risksR0..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) --> QcodesDriverandOphydDriverare config-only Python-object families whose fields are explicitparameter_bindings(QCoDeS Parameters) orsignal_bindings(classic Ophyd Components), each cited bysource_refand 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 (stopblockedwith the G7 finding). <!-- end exemplars-pyobj -->McpClientDriveris config-only too:mcp_config(the pinned source file and one entry per selected MCP tool),commands,actionsandreads. mcp-client.md gives the contract; the family exemplar isMHS/drivers/labs/mattjax16__rigol-dho-mcp. Every non-fake backend refuses, so such a package is fake-only.Sila2Driveris config-only as well:sila2_config(the pinned FDL, proto and implementation with their SHA-256 and the complete member table, usually indeclarations.py),commands,actions,readsand the feature's Stop insafety_bindings. sila2-controlled-profile.md gives the contract; the family exemplar isMHS/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.PyLabRobotDriversets oneplr_profile = PlrProfile(...)(plus read-onlyparameters/reads); its operations, limits, stop policy, fake model (mhs.testing.pylabrobot) and G7 source checks are in pylabrobot-helper.md. Itsstop()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 is | Write |
|---|---|
| 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 bindings | a 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 protocol | a 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 model | GymnasiumDriver, MujocoDriver |
| ROS 1/2 topics, services and actions exposed through rosbridge | RosDriver; 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 device | McpClientDriver 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 requiredsampled_atis 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:
| Raise | Receipt |
|---|---|
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 exception | failed MHS.ADAPTER.SHIM_FAILED, message redacted |
- Never catch a failing native call and carry on (G6
FAILURE_HIDDEN). @estopmay 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 (ormax_duration_msfor long-running actions) and cancelled on an e-stop.- A
safe_state=Trueaction runs inside the e-stop too, after the@estopmethod, so a silent device must not hold it. When the upstream method it calls waits for a reply with a long default (await_forof 60 minutes), send the command through the native send path or pass a short timeout, and say so infacts.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-003fails G8.
5. Limits and provenance
- Declare a limit once, on the field it bounds. A bare
min/maxon an array bounds every element.min=[...],max=[...]withnames=[...]andField(length=N)is per element.path="a.b","a[*]"or"a[0]"reaches nested leaves. sourceisvendor_docorupstream_code, andrefis"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.gitmodulesdeclares it and its checkout is at that commit (absent, empty or at another commit:REF_MISSINGwith the reason), and names each such submodule in the warningG1.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 underwork/companionscarrieslock, andtools/fetch_companions.pyputs it back in an export). Never invent a bound. If no source states it, useLimit.operator_required("why"): every call that sets the field is refused withMHS.SAFETY.LIMITS_UNSETuntil the operator'ssite-limits.jsonsupplies it.- An
operatorbound never goes indriver.py(G3LIMIT_SOURCE). It goes insite-limits.json; seeMHS/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.jsonis 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 reportsREF_BINARYbecause it cannot read the page,collect.pylists the citation asUNVERIFIABLE_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 as5-6000or[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: useLimit.operator_requiredand say why in its reason. - Hardware or hybrid R1+ drivers limit every numeric write or invoke input, or mark it
operator_required(G3LIMITS_UNSET). Leave catalogue inputs you cannot bound out of the method signature. - A
GymnasiumDriverdeclares noLimitfor its action: the bridge derives thecap.world.step.v1bounds from the action space of the environment it serves. Whengymnasium.make(env_id)built that environment, they areupstream_codewithref<env_id>:action_space. When abackendsentry built it (the package's fake), they carry nosource, and theirrefnames the backend (action_space of the fake backend .fake:FakeX);REFERENCE.mdprints it afterno source:. If the fake copies the upstream's space, setaction_space_ref = "path/in/clone:LINE"to the line that defines it (theBox(...)in the environment's__init__, say; aDiscrete(n)shows its largest actionn - 1, andBox(-np.ones(4), np.ones(4))shows -1 and 1). The derived bounds are thenupstream_codewith that ref, and G4 checks the line as it checks the ref of aLimit(REF_VALUEwhen the line does not show the bounds). When theBoxtakes 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 containhigh = np.ones(n)andBox(-high, high), including one literal scale defined in that same span (self.max_torque = 1000, thenhigh = 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, usingpath: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
MujocoDriverdeclares noLimitfor its action either: the bridge derivesaction.ctrlfrom the model'sactuator_ctrlrange(upstream_code,ref<model>:actuator_ctrlrange). An actuator the model gives no control range is never published as unbounded: it isoperator_required(the wholeaction.ctrlwhen no actuator has a range, elseaction.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'ssite-limits.jsonbounds it:action.ctrl(a scalar, or one value per element) for the first case, oneaction.ctrl[N]entry per listed element for the second; an overlay may only narrow a model range. Ship asite-limits.example.jsonwith such entries (operator data:"source": "operator", a reason that startsexample 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 citedLimiton the step action,actions = {"cap.world.step.v1": Action(name="step", limits={"action.ctrl[7]": Limit(...)})}(or on the wholeaction.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_requiredon a ranged actuator (or on the wholeaction.ctrlof 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_docandupstream_codefacts and limits.watchdog_msneeds aFactthat cites the device's own watchdog: the runtime implements no watchdog. - Hardware R2+ needs
estop_physical_ref(path:LINEof 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 setsreviewed_byindriver.lock.json.
6. Fakes and backends
- Backend names are
hardware,fakeandsim, and nothing else.fakeandsimmay not be the hardware entry point, may not use the.nativemodule, and may not be or derive from thenativeclass (G3FAKE_IS_NATIVE). - A fake lives in the package (
.fake:FakeX). The only exception is an upstream simulator, when the card'smock_strategy.preferredisupstream-sim(otherwise G3FAKE_OUTSIDE_PACKAGE). Prefer, in order: the upstream simulator, a recording fake, a transcript. - A fake implements only attributes the upstream
nativeclass 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>/fakeand fidelityF0. Its evidence iscontract_checked, never an MHS-Device level. - The runtime reads every
@readbefore 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@estopmethod and then everysafe_state=Trueaction: 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 aFact, aLimitor 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 inRESULT.jsonnotes and the fidelitygaps. 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 intoRESULT.jsonnotes and, for a simulator, into the fidelitygaps. 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 withNEEDS_ARTIFACTS. mhs.driver new --likecopies every non-generated file of the exemplar (native.py,fixtures/,site-limits.example.json) and writesdriver.lock.jsonfor the template kind of the card. Rewrite or delete what it copied, and set the lock'sbackendsto your class's (G2BACKENDS_DIFFERprints the value).fixtures/conformance.jsonsupplies inputs that synthesis cannot build, for example strings with apatternor allowed sets:
{"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, importmhs.server,mhs.mcp,mhs.conformanceor the gate, patchmhs, or reach intosys.modules, frames or builtins (G3FORBIDDEN). This includestest_driver.py. Usemhs.adapter.loadandmhs.testinginstead. 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.
| Gate | Checks |
|---|---|
| G1 | required 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 |
| G2 | driver.lock.json validates and pins the card's package id, commit, repository and licence |
| G3 | import 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 |
| G4 | every 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) |
| G5 | reference.json and REFERENCE.md equal a fresh reference run |
| G6 | behaviour 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 |
| G7 | attributes 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 |
| G8 | in-process conformance MHS-Sim CL3; the MCP projection builds; inputs have a description (numbers a unit); tools are at most 2048 bytes |
| G9 | test_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.
| Finding | Fix |
|---|---|
G1.MISSING_FILE, G1.MISSING_FAKE | add the file; a hardware driver needs fake.py or fixtures/ |
G1.CARD_MISMATCH, G1.CARD_EDITED, G1.CARD_INVALID | restore task.json from the registry card; never edit it |
G1.COMPANION_CARD | companions 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_CLONE | a 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_WRITTEN | the clone must exist at local_path and stay untouched; nothing may write into it |
G1.SYMLINK_OUTSIDE, G1.QUARANTINE | remove 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_UPSTREAM | the lock pins the card's package id, repository, commit and licence (new --card writes it) |
G2.COMMIT_ABSENT | the clone must contain the card's pinned commit |
G2.BACKENDS_DIFFER | driver.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_LOCK | only the tutorial has upstream: null |
G3.IMPORT, G3.DEFINITION | fix driver.py; the message lists every definition problem |
G3.STRICT | a 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_MISMATCH | build the card's safety.expected_mode; a hardware card needs a hardware driver with a fake backend |
G3.RISK_FLOOR, G3.UNKNOWN_KIND | raise risk to the card's floor; pick a kind from catalog kinds |
G3.NO_ESTOP, G3.NO_SAFE_STATE | hardware R1+: add @estop (or safety_bindings["estop"]) and a safe_state sentence |
G3.LIMITS_UNSET | give the numeric input a sourced Limit, Limit.operator_required(...), or drop it from the signature |
G3.UNSOURCED_LIMIT, G3.LIMIT_SOURCE, G3.FACT_SOURCE | cite vendor_doc/upstream_code with ref; operator bounds go to site-limits.json |
G3.FAKE_IS_NATIVE, G3.FAKE_OUTSIDE_PACKAGE | write a package fake; it must not be, derive from or re-export native (an upstream simulator needs mock_strategy.preferred: upstream-sim) |
G3.BACKEND_IMPORT | the backends entry cannot be imported; fix the .fake:Name path |
G3.FORBIDDEN, G3.SYNTAX | remove the construct named in the message (section 6) |
G3.GATE_TAMPERED | package code changed an mhs module; remove it |
<gate>.ABORTED | package code exited the gate's process, or the report is not the gate's own; remove exits and sys.argv use |
<gate>.TIMEOUT | a 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_LINE | cite 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.STALE | python3 -m mhs.driver reference <dir> |
G6.ENVELOPE_NOT_ENFORCED, G6.ENVELOPE_INVALID | declare the limit on the field the runtime checks; do not validate bounds in the driver |
G6.ENVELOPE_GAP | no 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_CANCELLED | long actions check self.cancelled() or are async |
G6.LATCH_NOT_ENFORCED, G6.AGENT_RESET_ACCEPTED | never override call, estop or reset; use the decorators |
G6.FAILURE_HIDDEN | let 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_INVALID | keep operator_required paths valid; site-limits.example.json may only narrow |
G7.NOT_UPSTREAM | the fake or driver uses a name that is not on the upstream class or in the clone; remove it |
G7.COMMAND_NOT_UPSTREAM | the 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_SYMBOL | native 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.