How it works
One gateway sits between everything that asks and everything that acts. This page walks through its parts, and follows one command from request to verified result.
Architecture
- Gateway (
openmhsd, oropenmhs serve). A single process that owns the devices. It listens on loopback only, and each client authenticates with its own token. - Interfaces. The MCP server for agents, a Python SDK using only
the standard library, and the
openmhsCLI. All three are clients of the same JSON-RPC API. - Roles. An operator manages devices, grants, tokens and approvals. An agent can read and submit commands within what it has been granted, and nothing more.
The life of one command
An agent asks the simulated power supply for 2 V.
- Request. The agent calls
submit_commandwith the device, the capabilitypower.output.voltage.setpoint, the value{"value": 2, "unit": "V"}and an idempotency key. - Journal. The gateway records the request before doing anything
else (
CREATED). - Authorize. Policy looks for a grant that lets this principal
write this capability on this device. No grant means
DENIED. - Validate. The value must have the right unit and be inside 0 to 12 V. The change from the current value must respect the slew limit of 2 V per second. The precondition "protection not tripped" must hold on a reading no older than one second.
- Approve, if needed. If the grant says
require_approval, the command waits inAWAITING_APPROVALuntil an operator decides. Preconditions are checked again after approval. - Dispatch. The intent is journaled, then the adapter sends the
write to the device (
DISPATCHED, thenACCEPTEDandAPPLIEDas the device reports). - Verify. After a short settle time the gateway reads the setpoint
back. Within tolerance (±0.005 V here) it becomes
VERIFIED. - Record. A receipt with the full history, the policy decision and the readback evidence is returned, and the audit log gains linked records.
Command states
REJECTED, FAILED, EXPIRED, CANCELLED and UNVERIFIABLE, is in spec/lifecycle.md.The rules that matter most:
- Acknowledgement is not effect.
ACCEPTEDandAPPLIEDare not success. Only a readback (VERIFIED), or a declaredUNVERIFIABLE, is. - Timeouts are uncertain, not failed.
TIMED_OUTalways moves toUNKNOWN_OUTCOME. - No blind retries. A command in
UNKNOWN_OUTCOMEis never sent again automatically. Until it is settled, a new write or action to the same capability is refused (command.outcome_unresolved), unless the capability is declared idempotent or is a stop. - Idempotency. The same principal, device and idempotency key return the existing command instead of running it again. Reusing a key with a different request is an error.
- Every command gets a receipt, including the ones that were denied.
Agents usually only need the coarse outcome:
| Outcome | States | Physical effect |
|---|---|---|
| succeeded | VERIFIED, RECONCILED_APPLIED | Confirmed by readback |
| unverified | UNVERIFIABLE | Device says yes; not independently confirmed |
| failed | REJECTED, FAILED, VERIFICATION_FAILED, RECONCILED_NOT_APPLIED | Not as requested |
| denied | DENIED | None, never sent |
| not_executed | EXPIRED, CANCELLED | None, never sent |
| uncertain | TIMED_OUT, UNKNOWN_OUTCOME | Unknown |
Unknown outcomes and readback
Suppose the gateway asks the supply for 7 V and the reply never arrives. Two things could have happened: the command was lost, or it was applied and only the answer was lost. From the gateway's side these look the same.
- Reporting failed would be wrong if the voltage did change.
- Retrying would be wrong if it did change and the action is not idempotent.
So OpenMHS reports UNKNOWN_OUTCOME, and nothing else is sent for that
command. When the device can be read again, the command is reconciled: the
gateway reads the device and records RECONCILED_APPLIED (it did take
effect) or RECONCILED_NOT_APPLIED (it did not). An adapter crash after
dispatch is handled the same way. Both cases appear in the
demo report.
An agent that retries anyway is refused before anything reaches the device: a
second write to the same capability waits until the first is settled. If the device
has no readback, an operator checks it and records the answer with a reason
(openmhs commands resolve); agents cannot. On SCPI instruments, a reply
that arrives after its timeout is discarded by a *IDN? resync, never read
as the answer to the next request, and nothing new is sent while the instrument is
still busy.
Principals, grants and approvals
- Principals are identities such as
operator:localoragent:claude. - Grants match a principal, a device, a capability and operations
(read, write, invoke), with an effect:
allow,denyorrequire_approval. Patterns such assim.*orpower.output.*are allowed. - Deny by default. With no matching grant, the answer is no.
- Credentials are scoped, expiring and revocable. A token's scope is a ceiling: the grants must also allow the call.
- Approvals are durable, single-use records bound to the exact command. Agents cannot grant themselves access or approve their own commands.
- Descriptors cannot grant. A device description declares limits, but permissions come only from the operator's policy.
Example: what an agent may do, set by the operator from the CLI.
openmhs grants add --id agent-read --principal 'agent:*' --device '*' --capability '*' --ops read --effect allow
openmhs grants add --id agent-psu --principal 'agent:*' --device sim.psu-01 --capability '*' --ops write --effect allow
openmhs tokens create agent:claude --scope 'sim.*:*:read' --scope 'sim.psu-01:*:write' --ttl 7d
Device descriptors
A descriptor is the device's contract. It is validated against JSON Schemas with stable error codes. An excerpt from the simulated power supply:
{
"name": "power.output.voltage.setpoint",
"kind": "property",
"operations": ["read", "write"],
"value": { "type": "number", "quantity": "electric_potential", "unit": "V",
"range": { "min": 0, "max": 12 } },
"side_effect": "reversible",
"idempotent": true,
"slew": { "max_change": 2, "per_ms": 1000 },
"rate_limit": { "max": 5, "per_ms": 1000 },
"preconditions": [
{ "id": "protection-not-tripped", "capability": "protection.tripped",
"op": "eq", "value": false, "max_age_ms": 1000 }
],
"verification": { "mode": "readback", "tolerance": { "abs": 0.005 },
"settle_ms": 50, "timeout_ms": 2000 }
}
Units use UCUM codes and are never guessed: a bare number where a quantity is
expected is refused. Readings carry a timestamp, a source and a quality
(GOOD, UNCERTAIN or BAD).
Adapters, profiles and the registry
An adapter translates between the gateway's contract and a device's protocol.
| Adapter | What it does | Evidence | Level reached |
|---|---|---|---|
| Modbus TCP | Registers and coils, from a JSON profile | Integration-tested (simulated peer) | L3 (declares L2) |
| MQTT | Topics, with verified TLS | Integration-tested (simulated peer) | L3 (declares L2) |
| SCPI (TCP or serial) | Bench instruments such as power supplies | Integration-tested (simulated peer) | L3 (declares L2) |
| HTTP | Devices with a web API, with verified TLS | Integration-tested (simulated peer) | L3 (declares L2) |
| OctoPrint (Python, experimental) | Read-only 3D printer status over OctoPrint's HTTP API | Integration-tested against OctoPrint's Virtual Printer | L1 (declares L1) |
| OPC UA (Python, experimental) | Read-only, using asyncua | Not verified: checked at L1 in CI, no report on file | none on file |
| ROS 2 Jazzy (Python, experimental) | Observation only, in a pinned container | Not verified: checked at L1 in CI, no report on file | none on file |
"Integration-tested" means the adapter spoke its real protocol, over real sockets, to a simulated device. It does not mean any particular real device works. The full, generated table is the compatibility matrix.
Modbus, MQTT, SCPI and HTTP devices need no code: a profile
(JSON) maps the device's registers, topics, commands or endpoints to capabilities.
openmhs profile new mqtt --id my.device-01 writes a starter profile from
the tested example of each protocol. Adapters written in other languages run as
out-of-process child processes over JSON-RPC on stdio. The adapter host
supervises them with heartbeats and bounded restarts, quarantines one that keeps
failing, and marks in-flight commands UNKNOWN_OUTCOME if it dies. Adapter
manifests declare permissions and can be signed with Ed25519.
The adapter registry
A git-based list of adapters, one JSON file each, in registry/. Each entry points at the conformance report that supports it, pinned by SHA-256, and the compatibility matrix is generated from the entries and reports. Categories describe evidence, not endorsement:
| Category | Meaning |
|---|---|
official | Maintained in this repository, with a passing report |
community | Maintained elsewhere, with a passing report and an https source |
experimental | Partial, or the evidence is incomplete |
unverified | Listed without evidence |
Simulators and fault injection
The virtual lab has three devices: a temperature sensor, a power supply and a linear stage. Each can be made to misbehave on request:
| Fault | What the simulator does |
|---|---|
link_down | All communication fails, reads included |
no_response | Commands get no answer and are not applied |
lost_ack | Commands are applied, but the answer never arrives |
garbled | Commands are applied, and the answer cannot be parsed |
refuse | The device refuses commands and applies nothing |
Conformance levels
The conformance suite tests an adapter together with its descriptor, sending real commands and injecting the faults above. Levels are cumulative.
| Level | Name | Means |
|---|---|---|
| L0 | Discoverable | Identity and capabilities can be inspected |
| L1 | Observable | State can be read with timestamps and quality; disconnects show as BAD, never as stale-but-good |
| L2 | Controllable | Authorized commands execute with verified outcomes; timeouts become UNKNOWN_OUTCOME; no retries of non-idempotent commands |
| L3 | Coordinated | Takes part in plans and leases; a stop gets through a lease |
A level is reached only when every check at that level and below passed or did not apply. A check that could not run blocks the level. A conformance level is not a safety certification: it says the integration behaves as specified under the tested faults, not that a device is safe to operate.
Plans, leases and the stop lane
- Capability graph. Devices declare relationships and shared resources, and the operator can add a topology file, so the gateway knows which devices affect each other.
- Leases. A lease claims a set of devices, all or nothing, and keeps other principals' commands off them while it is held.
- Plans. Ordered steps across devices under one lease. Each step is an ordinary command, with the same checks. When a step fails, the plan does what it declared: halt, compensate (undo earlier steps), or ask a person. A person decides every unknown outcome.
- Twin preflight. A plan can be checked against digital twins
first. The twins have no physics, so
okmeans the plan fits the contract and current readings, not that it will succeed. - Stop lane. Actions marked
stopskip the device lock and leases, so they reach a busy device. They are still authorized and validated, and they are not a safety function.
Where things are in the code
| Path | Contents |
|---|---|
spec/ | The specification and JSON Schemas. The source of truth. |
crates/openmhs-types | Descriptors, values, units, lifecycle, validation with stable error codes |
crates/openmhs-policy | Grants, credentials, constraints |
crates/openmhs-engine | Command engine, journal, approvals, leases, plans, twins |
crates/openmhs-gateway, openmhs-api | Gateway process and local JSON-RPC API |
crates/openmhs-cli | The openmhs and openmhsd binaries, demo, scaffolding |
crates/openmhs-mcp | MCP server |
crates/openmhs-host | Out-of-process adapter host, manifests, signatures |
crates/openmhs-sim | Virtual lab with fault injection |
crates/openmhs-conformance | Conformance suite and reports |
adapters/ | Modbus, MQTT, SCPI, HTTP, profiles, and Python adapters (OPC UA, ROS 2, OctoPrint) |
registry/, conformance/reports/ | Adapter registry entries and the committed conformance reports behind the matrix |
firmware/ | ESP32 test firmware used in Experiment 02 (run in QEMU) |
python/ | Python client SDK |
docs/adr/ | Architecture decision records: why each design was chosen |
Next: get started.