This release is a pre-release and may not be stable for production use.
robot-md-gateway
The enforcement gateway for the OpenCastor stack. Receives signed RCAN action envelopes, verifies manifest provenance, applies tier policy + tool allowlist, dispatches to drivers. Designed to be the only path between agent intent and any actuator; that holds only when the deployment makes it so (see Where this fits in the stack). Open source; intended to become OpenCastor's safety kernel via open-core extraction.
Renamed in 2026-05. This package was previously published as
robot-md-dispatcher. The old name is now a tombstone on PyPI;pip install robot-md-dispatchercontinues to work and pulls this package as a dependency. Imports and therobot-md-dispatcherCLI keep working through v0.4.x via a backward-compat shim. See CHANGELOG.md for migration notes.
Where this fits in the stack
robot-md-gateway is Layer 3 of the OpenCastor stack — the
enforcement gateway. It is designed to be the only path from agent
intent to any actuator. That holds only when the deployment makes it
so: the gateway runs as its own service account, and the GW-001 udev
rule makes that account the owner and group of the robot's device nodes
(mode 0660), so other non-root users get EACCES when they open them
(procedure). The default
systemd/install.sh does not set this up: it gives the device to the
dialout group and adds the installing user to that group, so another
local process can still reach the servos. Adding users to the device's
group, as the onboarding checklist
describes for interactive use, has the same effect.
| Layer | Piece | What it is |
|---|---|---|
| 1 — Declaration | robot-md | The ROBOT.md file + Python CLI. Declares identity, capabilities, safety gates. |
| 2 — Agent runtime | (any MCP host) | Claude Code, Codex, Gemini — plans actions, calls tools, and is not meant to reach actuators directly. |
| 3 — Gateway / Enforcement ← this | robot-md-gateway | Designed to be the only path to the actuators, when the deployment enforces it (see above). Verifies signatures, applies policy, signs audit bundles. |
| 4 — Robot-facing runtime | OpenCastor | Productized open-core runtime. Embeds the gateway as its safety kernel. |
| 5 — Protocol | rcan-spec | Wire format, envelopes, conformance suite. |
| 6 — Registry | Robot Registry Foundation | Identity (RRN/RCN/RMN/RHN), public keys, evidence. |
See the live compatibility matrix →
What it does
The gateway accepts incoming signed RCAN INVOKE envelopes — never plaintext goals, never SDK sessions. Every envelope is checked for:
- Manifest provenance — the ROBOT.md being actuated against has a verified signature from a key registered to this robot's RRN at RRF.
- Tier + RBAC — the caller's bearer token resolves to a tier authorized for this scope.
- Tool allowlist — the requested tool is in the operator's policy (default-deny on unknown). A tool the actuator declares as motion (
motion_capabilities) must also be invoked under an actuation scope (MANIPULATE,NAVIGATE,ACTUATE,EXECUTE,COMMISSION, in any case): the same motion relabelledOBSERVEis refused, because the tier, confidence and HiTL gates key off that caller-written field. For a motion tool those gates also apply the strictest motion scope's rule (HiTL on any of MANIPULATE, NAVIGATE, ACTUATE, EXECUTE covers all four), so relabelling among them buys nothing. - Confidence + HiTL gates (Phase 4 — Plan 6) — model-asserted confidence above threshold; human-in-the-loop approval if scope demands it.
- Replay protection + freshness (Plan 6; freshness v0.5.0a7): the envelope's
msg_idis checked against a bounded FIFO window of ids already seen (oldest evicted first), and when the envelope carries atimestamp_msit must fall inside a configurable window, by default 300 seconds either side of the gateway's clock. An envelope that carries notimestamp_msis not freshness-checked unlessROBOT_MD_REQUIRE_ENVELOPE_TIMESTAMPis on: the iOS client signs the field into its pre-image, older CLI signers do not send it at all, and refusing them all would be a silent break. The window is bounded, so this limits how long a captured envelope stays useful; it is not a permanent ledger of every id ever seen. - ESTOP precedence (Plan 6) — physical or operator stop signal preempts any pending action. A stop tool sent through
/v1/invoke(one an actuator declares instop_capabilities) runs on worker threads of its own (STOP_LANES), so it does not wait behind other requests for a thread; whether it then preempts a motion in progress is up to the actuator (so-arm101-actuator latches first and ends a paced move at its next setpoint). A flood of requests that saturates the gateway's CPU still delays it, because CPU and the GIL are shared by every thread (the stop latched 4.7 s after it was sent under 200 looping clients on two cores, in simulation; 0.05 s with no other traffic): the software stop is not the physical e-stop.
Which of those run depends on one setting, so read this before quoting the
list. Checks 1, 2, 3, 4 and 6 run on every request. The envelope signature
check itself, and check 5 (replay and freshness) which sits behind it, run
only when ROBOT_MD_REQUIRE_ENVELOPE_SIGNATURE is on, and it is off by
default. With it off the gateway still reads the envelope and still applies
the other five checks, but it does not require the envelope to be signed and
therefore does not check the id against the replay window or the timestamp
against the freshness window. Turning it on is one environment variable, and a
deployment that wants any of what check 5 describes has to turn it on. The
defaults are permissive on purpose, for bring-up; they are not the
configuration this section describes unless you set them that way.
If all checks pass, the gateway dispatches to a local actuation tool (typically a robot-md-mcp tool call or a direct driver invocation) and emits a signed audit bundle entry per action. If any check fails, the action is denied and the failure is logged + signed.
Record before dispatch, and record again after
The gateway writes its record before dispatch. This is implemented, not spec text. Since v0.5.0a8 the allow path writes two entries per invoke, in this order:
- an intent entry, written after every check above has passed and
before
target_actuator.execute()is called. It carries the tool, the envelope id and msg id, the caller and tier, the nonce and the name of the actuator about to be driven, signed with the same Ed25519 recipe the outcome uses. It says the gateway was about to dispatch. It says nothing about whether the dispatch happened. - an outcome entry, written after the actuator returned or raised. This is the entry that says what happened, and it carries the intent entry's chain hash so the pair is one hop apart.
Read the pair through that hash and the shared corr_id, never off the chain
by position. Two invokes at once are two threads, and A-intent, B-intent,
A-outcome, B-outcome is an ordinary interleaving: the entries stay correctly
linked and correctly ordered, but a pair need not be adjacent.
The record used to be written only after the dispatch, on purpose, so that it could say what actually happened. That reason is still true, which is why the late record stayed. What the late record could never cover is the case where nothing comes back at all: a driver that hangs, a process killed mid-motion, a robot unplugged between the gate and the wire. Those used to leave no trace that anything had been attempted. RCAN §6.3 makes the write before driver dispatch normative, and the pair is how both halves are true at once.
An intent with no outcome beside it is a dispatch that never reported. It is
not an action that happened, and scripts/verify_receipt.py --walk names it in
exactly those words rather than counting it either way.
Both writes are on the request path, before and after the dispatch, and on a
Pi 5 with the export on the SD card they add about 16.6 ms of blocking IO per
invoke, of which about 8.3 ms falls before the actuator is called. That is one
fsync per line and it is the floor for a record that is on disk before the
robot moves. An fsync has no timeout, so a failing card can make an invoke
slow; it cannot make it wrong, and unsetting
ROBOT_MD_ATTESTATION_EXPORT_FILE takes the export off the path entirely.
Both writes are best effort, unchanged from the contract the outcome record has always had: a signing failure, a full disk or an unwritable export is logged and swallowed. It never crashes the request and it never changes whether the robot moves. A record is evidence, not enforcement.
Is anything missing? --walk
Every NDJSON trace line written from v0.5.0a8 carries a seq (monotonic within
one export file) and a chain_prev (sha256 of the previous line's bytes), with
the head persisted in a sibling <export>.head file written before the line
it describes. Deleting or truncating a line now leaves a hole:
python scripts/verify_receipt.py --walk attestation-export.ndjsonl
No key and no network needed, so a third party handed the file can run it. Walking Bob's real 4437-line, 4.1 MB export takes 0.15 s on a Pi 5. Exit 0 is a clean walk, 1 is a gap or a chain break, 3 is named findings a person has to read. A clean walk means the numbering and the links agree with each other; it does not mean the file is complete. A line cut from the end, with the head file taken too, leaves nothing local to notice, which is the whole reason there is an off-box copy.
The file grows, and it is meant to
The export is append-only and there is no cap and no rotation. Two lines per invoke since v0.5.0a8, roughly a kilobyte each: Bob's export was 4437 lines and 4.1 MB before any of this, from one robot and no shipper. Plan for it the way you would plan for a journal, and watch the disk.
Rotation is deliberately not built in, and a rotated export reads as
tampering, which is the correct reading and not a bug. The shipper's offset
would land past the end of the shorter file and it stops with a named
TAMPER/TRUNCATED line rather than re-delivering, because from the outside a
rotation and somebody cutting the file are the same event. --walk on the new
file sees a seq that does not start where the old one stopped. If you must
move the file, do it deliberately: stop the gateway, move the export, the head
and the offset together, and keep the old file, because the off-box copy is the
only thing that shows what the local one no longer holds.
Lines written before v0.5.0a8 carry no seq and bind nothing; the walk says
how many there are and refuses to imply otherwise. The first numbered line after
them binds the last unnumbered line's bytes and is marked unnumbered_history.
A missing head file beside a numbered export is reported the same way, on the
line, as head_recovered_from_file: the gateway continues from what the file
itself still proves rather than refusing to append (which would destroy evidence
to protect the appearance of an unbroken chain) or silently restarting at 1
(which is the failure this whole format exists to end).
What it does not do
- ❌ Spawn LLM planners. That was the v0.2.x mode; it now ships as
--legacy-byok-launcherfor backward compat (deprecation-warned), removed in v0.4.0. Planners run in agent runtimes (Layer 2), separately, and produce signed envelopes that come to the gateway. - ❌ Be optional. If you can move the robot without going through the gateway, you don't have an enforcement gateway — you have a hint.
- ❌ Cover Layer 4. Drivers, fleet UI, cloud bridge belong to OpenCastor (or any future Layer-4 runtime); not here.
Where safety is meant to be enforced.
Physical limits are meant to be enforced at Layer 3 (
robot-md-gatewayand the actuator driver it calls), and only for commands that pass through it. OpenCastor (Layer 4) does not embed the gateway yet, so actuators it drives directly are not covered. Declaration alone (Layer 1) does not enforce safety. Agent host alone (Layer 2) is not the safety boundary. If a deployment lacks Layer 3, no safety claim attaches to it. Layer 3 is not a certified safety function, and hostile-input testing in simulation (October 2026) found motions it does not yet bound.
Status (v0.3.0a1)
This release lands the rename + scope-shift skeleton. The receive-only
RCAN handler, manifest provenance verification (test properties MF-001 /
MF-002), and direct-device-bypass denial (test property GW-001) ship in upcoming patch
releases under Plan 6. The legacy planner-launcher mode is preserved
behind --legacy-byok-launcher for one minor release.
Installation
pip install robot-md-gateway
Quick start (legacy mode, until receive-only ships)
python3 -m venv .venv
.venv/bin/pip install robot-md-gateway robot-md # robot-md-mcp ships with robot-md
.venv/bin/robot-md-gateway init --yes
.venv/bin/robot-md-gateway --legacy-byok-launcher serve \
--bearers ./bearers.yaml --robot-md ./ROBOT.md
init --yes writes bearers.yaml, .env, and dispatch-test.sh next to your
ROBOT.md and prints a generated actuate-tier token once. Save the token — it's
not stored anywhere else. Run robot-md-gateway init (no --yes) for a
guided walk that explains each knob.
Production install
systemd/install.sh handles the full setup: dedicated robot system user, /opt/robot-md-dispatcher/.venv with hardened unit, DeviceAllow=/dev/ttyACM0 rw, MemoryMax=1G, CPUQuota=80%, journal logging.
The on-disk names still carry the package's old name: the install goes to
/opt/robot-md-dispatcher, config to /etc/robot-md-dispatcher, and the unit
is robot-md-dispatcher.service. The installer writes
/etc/robot-md-dispatcher/dispatcher.env with absolute paths; the unit reads
that file, not a .env.
Run robot-md-gateway init --yes first (next to your ROBOT.md) to generate
bearers.yaml, .env, and dispatch-test.sh. Then:
sudo ./systemd/install.sh
sudo cp ./bearers.yaml ./ROBOT.md /etc/robot-md-dispatcher/
sudo systemctl daemon-reload && sudo systemctl enable --now robot-md-dispatcher
Onboarding checklist for the operator
The systemd service runs as the unprivileged robot user, which the install
script adds to dialout so it can open /dev/ttyACM*. The interactive human
who installs the gateway usually also wants to run robot-md,
robot-md-mcp, or robot-md-gateway from their own shell — and that
requires the same group membership for their UID. Without it,
backend.open fails with EACCES and the MCP server silently falls through
to "no backend" mode (see issue #21).
systemd/install.sh handles this by default: it adds $SUDO_USER to
dialout alongside the service user. To opt out (strictly service-only
install), pass --no-interactive-user:
sudo ./systemd/install.sh --no-interactive-user
You must log out and back in for the new group membership to attach to your login session. After re-login, verify with:
groups | grep -E 'dialout|robot-md-gateway' && ls -l /dev/ttyACM0 && echo OK
If /dev/ttyACM0 is owned by a custom group (e.g. a site-local udev rule
that hands the device to robot-md-gateway:robot-md-gateway rather than
dialout), add yourself to that group too. Pass the username explicitly —
under sudo, $USER is root and would add the wrong account:
sudo usermod -aG <group> <your-login-username>
# or, programmatically: sudo usermod -aG <group> "$(logname)"
Ingress — do not port-forward
The gateway binds to 127.0.0.1 by design. Expose it via Tailscale Funnel (named, revocable, TLS-terminated):
tailscale serve --bg --https=443 http://127.0.0.1:8080
tailscale funnel 443 on
Configuration
Environment variables (also settable via CLI flags — flags win):
| Variable | Purpose | Default |
|---|---|---|
ROBOT_MD_PATH |
Path to the ROBOT.md this gateway enforces. When set, /v1/invoke denies (403 manifest_pin, audited) any envelope whose manifest_path resolves to a different file. Unset = no pin; bench only |
unset |
ROBOT_MD_BEARERS_FILE |
Path to bearers.yaml |
required |
ROBOT_MD_MCP_COMMAND |
Stdio MCP command the gateway dispatches to | robot-md-mcp |
ROBOT_MD_MCP_ARGS |
Space-separated args for the MCP command | (none) |
ROBOT_MD_LOG_LEVEL |
Python log level | INFO |
ROBOT_MD_REQUIRE_ENVELOPE_SIGNATURE |
Require every envelope to carry a signature this gateway can check. The replay window and the freshness window below only run when this is on. | off |
ROBOT_MD_ENVELOPE_MAX_SKEW_S |
Half-width of the envelope freshness window, in seconds, both directions. Unparseable, zero or negative values log a warning and fall back to the default, because a zero window would deny every envelope that carries a timestamp | 300 |
ROBOT_MD_REQUIRE_ENVELOPE_TIMESTAMP |
Deny an envelope that carries no timestamp_ms instead of letting it through unchecked |
off |
What a client gets back
/v1/invoke answers with exactly three shapes. A client that handles these
three handles every tool on every actuator.
Allowed and executed — 200:
{
"ok": true,
"manifest_kid": "bob-manifest-2026",
"scope": "MANIPULATE",
"tool_name": "arm.move_to",
"actuator_name": "so-arm101",
"outcome_kind": "executed",
"telemetry": {"...": "whatever the driver measured"},
"attestation": "attested",
"outcome": {"...": "the signed receipt"},
"envelope_signature": {"kid": "...", "alg": "Ed25519", "sig": "..."}
}
Denied — 403. By a gateway gate, or by the driver's own policy. Either
way it is signed, audited, and safe to keep as evidence:
{
"detail": {
"deny": "actuator_policy",
"reason": "out_of_workspace: x=500mm is outside the declared workspace (-200 to 340mm)",
"actuator_name": "so-arm101",
"telemetry": {"deny": "out_of_workspace", "reason": "x=500mm is outside ..."},
"attestation": "attested",
"envelope_signature": {"kid": "...", "alg": "Ed25519", "sig": "..."}
}
}
detail.deny names which gate refused (tier_policy, tool_allowlist,
manifest_provenance, safety_state, actuator_policy, …). For
actuator_policy — the driver's own refusal — detail.telemetry carries the
driver's machine-readable code when it produced one; branch on that, not on the
wording of reason. The key is absent when the driver had nothing structured to
say.
Broken — 500. The driver raised. A fault is never dressed up as a
decision, so it does not arrive as a deny and carries no receipt.
What is inside the signed receipt
The outcome object is the receipt. Its bytes are what the Ed25519 signature
covers, so every field listed here is bound to the signature and cannot be
edited without breaking it.
{
"receipt_version": 2,
"corr_id": "the envelope's msg_id",
"rrn": "RRN-... (the robot, from its signed manifest)",
"status": "ok | denied | failure | error",
"started_at": "2026-09-14T...", "ended_at": "2026-09-14T...",
"caller": "craig-iphone",
"tier": "actuate",
"envelope_signature": {"kid": "...", "alg": "Ed25519", "sig": "..."}
}
caller names a CREDENTIAL, never a person. It is the caller field of
the bearer entry in bearers.yaml that authorised the request, the name the
operator wrote beside a token (craig-iphone, host-config,
readonly-probe). It says which token was presented. It does not say who was
holding the device, and no field in this receipt does. A bearer entry with no
caller declared produces "caller": null, which is the honest answer rather
than a guess.
receipt_version tells a reader which shape they have. Receipts signed before
v0.5.0a7 carry no receipt_version key at all, no caller and no tier;
those are version 1 and they stay valid forever. scripts/verify_receipt.py
accepts both, and prints which one it read:
python scripts/verify_receipt.py --receipt receipt.json --pubkey gateway.pub
Exit 0 means the bytes carry a signature from the key you supplied AND a
one-byte-flipped copy was rejected. On a version 2 receipt the flipped field is
caller, so a hand-edited caller exits non-zero. That is all a pass means: the
record has not changed since it was signed. It is not a statement that the
action was safe, correct, or authorised by any particular person. A signed
receipt is an accountability artifact, and reading it is the check.
Development
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -q
.venv/bin/ruff check src tests
The test suite mocks external SDK boundaries via Protocol shims, so pytest runs offline. The tier gate, auth, and HTTP surface are exercised end-to-end with a TestClient. Real tool names from robot-md-mcp's server are pinned in tests/test_gating.py; if the upstream tool surface shifts in a way that inverts a read/actuate classification, the test fails loudly.
License
Apache-2.0. See LICENSE.
Metadata
Release files for robot-md-gateway 0.5.0a9
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| robot_md_gateway-0.5.0a9.tar.gz | 250.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| robot_md_gateway-0.5.0a9-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 339.9 kB
Release files / robot_md_gateway-0.5.0a9.tar.gz
| Download URL | robot_md_gateway-0.5.0a9.tar.gz |
|---|---|
| Size | 250.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f16fc0cb183b25005a7aba1b96bf5cbfa82fc928b90f50a54c5507d7affcf3d4
|
|
BLAKE2b-256 checksum How to use checksums |
6fb23be398dd288d340e164f516c6504905ad2639d753ed90fe16e5f017b033a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / robot_md_gateway-0.5.0a9-py3-none-any.whl
| Download URL | robot_md_gateway-0.5.0a9-py3-none-any.whl |
|---|---|
| Size | 89.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9224c3193bcf8510b2e2109f79d687d37832918b911d82e90b86149951a90b5a
|
|
BLAKE2b-256 checksum How to use checksums |
77c594ada5dde8e2e3cd24709dc97e0053083266eaa792f01deb57abefa95358
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log