This release is a pre-release and may not be stable for production use.
robot-md-gateway
The mandatory enforcement gateway for the OpenCastor stack. Receives signed RCAN action envelopes, verifies manifest provenance, applies tier policy + tool allowlist, dispatches to drivers. Exclusive path between agent intent and any actuator. Open, neutral, becomes 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. Every action that crosses from agent intent to
any actuator passes through this gateway. There is no second path.
| 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, never reaches actuators directly. |
| 3 — Gateway / Enforcement ← this | robot-md-gateway | Mandatory exclusive path. 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).
- 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.
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 actually enforced.
Physical safety is enforced at Layer 3 (
robot-md-gateway) or Layer 4 (a runtime that embeds it, e.g., OpenCastor). 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.
Status (v0.3.0a1)
This release lands the rename + scope-shift skeleton. The receive-only
RCAN handler, manifest provenance verification (cert MF-001 / MF-002),
and direct-device-bypass denial (cert 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-gateway/.venv with hardened unit, DeviceAllow=/dev/ttyACM0 rw, MemoryMax=1G, CPUQuota=80%, journal logging.
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 ./.env /etc/robot-md-gateway/
sudo cp ./ROBOT.md /etc/robot-md-gateway/ROBOT.md
sudo systemctl daemon-reload && sudo systemctl enable --now robot-md-gateway
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 loaded as the manifest under verification |
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.0a8
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.0a8.tar.gz | 237.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| robot_md_gateway-0.5.0a8-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 321.6 kB
Release files / robot_md_gateway-0.5.0a8.tar.gz
| Download URL | robot_md_gateway-0.5.0a8.tar.gz |
|---|---|
| Size | 237.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fcf5245c257a16d27784cae18b3d3297d85d9f4159f407117cce37efee0963d0
|
|
BLAKE2b-256 checksum How to use checksums |
0c3b193aa451e3138457d16744acbccf5278e66e579e92de6f0c43381e805317
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / robot_md_gateway-0.5.0a8-py3-none-any.whl
| Download URL | robot_md_gateway-0.5.0a8-py3-none-any.whl |
|---|---|
| Size | 84.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
eb9d16d5a5028dff678bb8191eb6462ad39c50a7db2307a1cf92cc45e10bac29
|
|
BLAKE2b-256 checksum How to use checksums |
2da9a90fc396a7b44965b6c5e804210ef06dc761265bee8c332e75b1712f0041
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|