Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol.
21 KiB
Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
+--------------------+ HTTP Loopback +--------------------+
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
+--------------------+ +--------------------+
|
Platform-specific transport
v
+--------------------+
| External Platform |
| (Mobile / Client) |
+--------------------+
1. System Topology & Custody Boundaries
Outbound Custody & Recovery
- OpenRig enqueues outbound decisions to a local
DispatchBufferbefore issuing any HTTPPOSTtoOPENRIG_RELAY_URL. - Each logical decision freezes its complete
relayEnvelopeonto the parentDispatchBufferentry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting. - Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP
POSTper attempt withdeliveryId = <decisionId>(preserving the fullbody,humanDetail,humanQuestions/options,summaryoverflow, and digestitemswithout silent clipping). - An HTTP
2xxresponse from the bridge acknowledges durable bridge custody of that complete envelope (deliveryId). The bridge must persist each envelope bydeliveryIdand deduplicate replayeddeliveryIds idempotently. - OpenRig records the logical queue acceptance receipt (
relay-owner-notification-acceptedwithdeliveryId) and drains the decision fromDispatchBufferonce the HTTPPOSTreturns2xx. Bridge acceptance confirms custody by the bridge process only (and is projected asaccepted/"Accepted by bridge", whereas Slack posts useposted/"Delivered"). It does not indicate downstream platform delivery, posting, or human receipt. - Failed dispatches remain in OpenRig's
DispatchBufferand record a relay-specific failure transition note (relay-owner-notification-transport-failednaming theconnectorRef, distinct from Slack'sslack-owner-notification-transport-failed). Replays after a lost ACK or daemon/bridge restart re-send the exact frozenrelayEnvelopewith the samedeliveryIdand bytes. If the relay subsystem stops while an outbound HTTPPOSTis in flight (or before it starts), the transport aborts cleanly (stopped) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
Inbound Custody & Resolution
- The operator bridge posts replies to
POST /api/gateway/relay/inbound. - Requests require
Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>matching a configured, ready relay profile. - OpenRig verifies that the caller is a direct loopback peer (
127.0.0.1/8or::1) over the underlying TCP socket. Proxy and forwarding headers (Via,X-Forwarded-*,Forwarded,X-Real-Ip) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token. - Sender attribution resolves by matching
sender.handleagainst human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or insidesenderare ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform. - When an inbound reply is accepted (
200 OK), OpenRig reserves the event inrelay-inbound-seen.jsonl(received:<fingerprint>), durably commits the answer/resolve action and itsmission_control_actionsaudit record atomically through its Mission Control write contract (using existingexcludeOperationReplyrevision checking without a second answer store or synthetic revision refresh), and marks the eventprocessed:<fingerprint>. Exact duplicate replays of the sameeventIdand payload succeed idempotently (deduplicated: true).
Exclusions & Scope
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
2. Secrets & Environment Configuration
Each relay connector loads configuration from an absolute file referenced by the binding's secretsRef (file:<absolute-path>).
Security & File Requirements
- Absolute Path Only: Must begin with
file:/. Relative paths, bare paths, andfile://authority URLs are rejected. - File Permissions: The file should restrict access to the owner only (no group or other permission bits set, i.e.
(mode & 0o077) === 0, such as0600or0400). Owner-only modes (0600,0400) produce no warning. If group or world permission bits are set (e.g.0644), OpenRig logs a redacted warning in the profile's readinesswarningsandreason(matching the Slack secret-file posture) while keeping the profileready. Missing or unreadable secret files remainnot-ready. - No Environment Fallback: OpenRig does not fall back to process environment variables (
process.env). All variables must come directly from the referenced file. - Profile Independence: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report
not-ready. Other connectors remain functional.
Required Secrets Keys
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
OPENRIG_RELAY_URL: Must usehttp:, loopback host (127.0.0.1/8,::1, orlocalhost), without URL credentials or hash fragments. Redirects (3xx) are rejected.OPENRIG_RELAY_OUTBOUND_TOKEN: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.OPENRIG_RELAY_INBOUND_TOKEN: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (connectorRef); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (not-ready). Using the same token value for bothOPENRIG_RELAY_OUTBOUND_TOKENandOPENRIG_RELAY_INBOUND_TOKENwithin a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
3. Wire Protocol Specifications
Outbound Envelope (POST <OPENRIG_RELAY_URL>)
{
"v": 1,
"deliveryId": "dec-019283-abc",
"recipient": { "handle": "alice-ops" },
"intent": "decision",
"outcome": "interrupt",
"summary": "Database Migration Approval",
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
}
v: Protocol version integer (1).deliveryId: Stable whole-decision delivery identifier (<decisionId>).recipient.handle: Recipient's registered safe alias for this connector.intent:"decision"(actionable question) or"update"(informational notice).outcome: Delivery classification ("interrupt","notify", or"digest").summary: Plain text summary preview (up to 1,000 characters on thesummaryfield, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning ofbodyoritem.body).body: Complete plain text content (including fullsummaryoverflow,body,humanDetail, and renderedhumanQuestionsheaders, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.replyRef: Fixed-size opaque base64url token (<= 4,096characters for any supported ID lengths —246–266characters in protocol v1, hashing the canonical frozenRelayReplyRefPayloadand bindingconnectorHash,mode, and optionaldigest: true) for actionable decisions;nullfor informational updates (andnullat the top level of multi-item digests).items: Present whenoutcomeis"digest". Array of objects withqitemId,notificationKey,summary, optionalbody, and per-itemreplyRef(ornull).
Inbound Envelope (POST /api/gateway/relay/inbound)
{
"eventId": "evt-relay-20261107-001",
"sender": { "handle": "alice-ops" },
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
"text": "Approved. Proceed with rollout.",
"ordering": "after-question"
}
eventId: Bridge-assigned identifier (1..256chars, matching/^[A-Za-z0-9._:-]{1,256}$/).sender.handle: Sender's alias, matching/^[A-Za-z0-9._-]+$/. Must match a human fragment bound to the authenticated connector. Harmless extra fields insidesenderor on the top-level JSON object are ignored and never overridesender.handleresolution or affect the event fingerprint.replyRef: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alterreplyRef.text: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.ordering: Optional enum ("after-question"|"uncertain", default"uncertain"). When"uncertain", text is accepted as recorded context rather than verified sequence relative to the question.
Inbound Statuses & Responses
- 200 OK: Request accepted (
{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }). Exact duplicate submissions of the sameeventIdreturnok: truewithdeduplicated: true. - 400 Bad Request: Malformed JSON, non-object payload, or invalid required field syntax/bounds (
eventId,sender.handle,replyRef,text, orordering). Harmless extra fields are ignored. - 401 Unauthorized: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (
noHumans). - 403 Forbidden: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (
replyRefemitted by another connector), or unregisteredsender.handle. (ViaandX-Forwarded-*headers are ignored). - 404 Not Found: Queue item referenced in
replyRefdoes not exist. - 409 Conflict: Stale
decisionRevision(question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognizedreplyRefwhen both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existingeventId. - 413 Payload Too Large: Request body exceeds 64 KiB (65,536 bytes).
- 503 Service Unavailable:
human_registry_unavailable: Local human registry (gateway/humans/*.yaml) failed to load due to a broken fragment or filesystem read error (normalnoHumansreturns401).queue_store_unavailable: Local SQLite queue/transition store read failed while resolving or verifyingreplyRef, returning503with zero seen-store reservation or Mission Control action side effects.dispatch_buffer_unavailable: An un-receiptedreplyRefrequired fallback verification against<openrig-home>/gateway/dispatch-buffer.json, and reading the buffer failed (pendingStrict()), returning503with zero seen-store reservation or Mission Control action side effects. (replyRefs already vouched for by durable accepted receipts inqueue_transitionsorqueue_transitions_archiveare verified before consulting the buffer and still succeed.)seen_store_unavailable(response includesseenPath): Local seen-ledger (state/relay-inbound-seen.jsonl) is unreadable, contains unparseable JSONL or unkeyable records (missingid), failed a reservation/processed mark, or has a corrupt/unvouched record for the requestedeventId's key. Keyed suspect records (missing/invalid statusor conflicting recorded fingerprints for the same key; diagnostictsor extra metadata fields are ignored) scope refusal to thateventIdkey only so unrelatedeventIds continue to operate normally, while never treating unparseable or suspect history as unseen.
4. Identity & Binding Configuration
Bindings are managed through human fragment files (gateway/humans/<entityId>.yaml) and CLI verbs (rig gateway human).
Binding Syntax
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
kind:relay.connectorRef: Identifier for the connector instance (e.g.local-bridge).secretsRef: Absolute file path reference (file:/path/to/relay.env).role:primaryorsecondary(at least one primary binding required for outbound dispatch).handle: Optional platform alias (handle=<alias>). Required for inbound reply resolution; omitted means outbound-only.
Handles must match /^[A-Za-z0-9._-]+$/ and are unique per connectorRef. Multiple people can share a connector, each registered with their own handle.
5. Operational Lifecycle
Step 1: Provision Secrets File
Create an owner-only secrets file (no group/other access bits, e.g. 0600 or 0400):
mkdir -p "$HOME/.config/openrig-relay"
touch "$HOME/.config/openrig-relay/relay.env"
chmod 600 "$HOME/.config/openrig-relay/relay.env"
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
EOF
Step 2: Register Human Binding
# Add a person with a primary relay binding
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
# Replace an existing binding slot (requires the full binding spec)
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
Note
rig gateway human set alice binding.<n>requires a full binding specification string. Supplyingunsetis not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
rig gateway default alice
Step 3: Activate Daemon
When bindings or secrets files change, restart the daemon process:
rig daemon stop
rig daemon start
(OpenRig does not provide a rig daemon restart command).
Step 4: Verify Readiness & Status
# Check delivery readiness for all participants
rig gateway human list
# Inspect detailed readiness status for an individual
rig gateway human show alice
# Check daemon process status
rig daemon status
In rig gateway human show <entityId> --json, deliveryReadiness.state reports ready when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
Step 5: Removing Bindings or Participants
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
rig gateway human remove alice
(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless --force is specified).
6. Troubleshooting Common Conditions
| Condition | Observation | Resolution |
|---|---|---|
| Missing Secrets File | deliveryReadiness.state: not-readyreason: relay secret file ... is missing or unreadable |
Verify file path in secretsRef is absolute and readable. |
| Insecure Permissions | deliveryReadiness.state: ready (with warning)reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...) |
Restrict permissions so group/other have no access (chmod 600 <secrets-file> or chmod 400 <secrets-file>). |
| Identical Directional Tokens | deliveryReadiness.state: ready (with warning)reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ... |
Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
| Invalid Loopback URL | deliveryReadiness.state: not-readyreason: ... OPENRIG_RELAY_URL host must be loopback ... |
Ensure URL uses http:// and host is 127.0.0.1/8, ::1, or localhost. |
| Reused Inbound Token Across Profiles | deliveryReadiness.state: not-readyreason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ... |
Each distinct relay connector profile (connectorRef) must declare a unique inbound token. |
| Stale Question Reply | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
| Unregistered Handle | HTTP 403 on inbound endpoint | The sender.handle is not bound to the authenticated connectorRef. |
| Non-Loopback Inbound | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (Via and X-Forwarded-* headers are ignored). |
| Registry or Buffer Read Failure | HTTP 503 on inbound endpoint (human_registry_unavailable or dispatch_buffer_unavailable) |
Inspect local gateway/humans/*.yaml fragments or <openrig-home>/gateway/dispatch-buffer.json for syntax/permission corruption before retrying the bridge event. |
| Seen-Ledger Corrupt / Suspect | HTTP 503 on inbound endpoint (seen_store_unavailable, includes seenPath) |
Unreadable/unparseable/unkeyable relay-inbound-seen.jsonl refuses all inbound requests; a keyed suspect entry refuses only that specific eventId. Follow the conservative recovery procedure below. |
Conservative Seen-Ledger Recovery (seen_store_unavailable)
When POST /api/gateway/relay/inbound returns 503 with error: "seen_store_unavailable", the response body and message include the resolved seenPath (<openrig-home>/state/relay-inbound-seen.jsonl):
- Quiesce writers first: Pause the loopback bridge and stop the daemon (
rig daemon stop) so no concurrent inbound request or retry appends to the ledger during inspection. - Preserve a full backup: Copy
<seenPath>to a timestamped backup outsidestate/before making any edit. - Correlate against durable evidence: Each ledger entry keys
idbyeventKey = SHA256(JSON.stringify([connectorRef, eventId]))(64-character lowercase hex digest) and storesstatusasreceived:<fingerprint>orprocessed:<fingerprint>. BecauseeventKeyis a one-way hash, correlate any damaged line or keyed suspectidagainstmission_control_actions.audit_notes_json(which recordsoperationId: "relay:<eventKey>:<fingerprint>",connectorRef, andeventId) and bridge delivery logs by computingSHA256(JSON.stringify([connectorRef, eventId]))for candidate events—never by guessing or assuming the hash is reversible. - Apply only evidence-verified, history-preserving repair: Repair a damaged record only when its exact
id(<eventKey>) andstatus(received:<fingerprint>orprocessed:<fingerprint>) are verified frommission_control_actions.audit_notes_jsonor bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do not delete, truncate, or skip unknown/unparseable records, and do not restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with503) and retain the backup. - Resume and retry: After a verified, history-preserving repair is saved at
<seenPath>, start the daemon (rig daemon start), resume the loopback bridge, and retry the original inbound delivery with the sameeventId.
For automated loopback bridge test reference, see packages/daemon/test/fixtures/recording-relay.ts.