Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


title: Outbox Approval Protocol tags: [pubsub, approval, discord, email]

Outbox Approval Protocol

Outbox services can hold an outbound message for human approval before sending it. The agent never sends directly; it writes to an outbox stream, and a broker service decides whether to forward the message, optionally gating it behind a human reaction.

Source: pkgs/bridge/base.py (OutboxService), pkgs/bridge/ingest.py (IngestService), with per-platform implementations in pkgs/discord/ and pkgs/email/.

See also Security Model.

Transport

Messages move over Redis Streams. Each bridge {name} has an inbox stream ({name}:inbox) and an outbox stream ({name}:outbox). OutboxService reads its outbox via a consumer group ({name}-outbox / worker {name}-worker) and acknowledges each entry after handling it. Approval state lives in plain Redis hashes so any service can poll it.

Flow

When approval_required is set, an outbound message is held and routed through a notification channel:

  1. The agent writes a message to its outbox — {agent}:{source}:outbox under the per-agent ACL (the flat {source}:outbox on a shared Redis).
  2. OutboxService.process_one() sees approval_required and the message has no approval_id, so it parks the entry (_resume_or_start_approval):
    • creates approval:{uuid} with status=pending, source, channel_id, a content_preview (first 200 chars), created_at and expires_at
    • writes a notification to the configured notify outbox ({approval_notify}:outbox) carrying the approval_id
    • returns without acking, so the rest of the stream keeps moving
  3. The notify service (e.g. Discord) sends the notification. Because the message carries an approval_id and the send returns an external_id, the base loop records the mapping in approval:{source}_map (external id → approval id).
  4. A human reacts to the notification.
  5. The notify service’s ingest handler calls OutboxService.resolve_approval(), which looks up the approval id from the map and, if the record is still pending and inside its window, sets status to approved or rejected plus the approver. A late answer — a record already decided, past expires_at, or already settled and deleted — is ignored; the map entry is dropped either way.
  6. The original outbox service checks its parked entries once per loop (poll_awaiting, via check_approval, which reads the deadline before the decision); it sends the message on approved, or acks it and tells the agent on its inbox that it was rejected or expired.

State machine

approval:{uuid} = {
  status:          pending | approved | rejected | expired
  source:          email | discord | ...
  channel_id:      destination (email address, channel id, ...)
  content_preview: first 200 chars of the message body
  created_at:      unix timestamp
  expires_at:      unix timestamp; past it the record reads as expired
  approver:        who resolved it (set on resolution)
}
pending --+-- approve --> approved --> send
          +-- reject  --> rejected --> drop, notify the agent
          +-- timeout --> expired  --> drop, notify the agent

expired is never written: it is what check_approval answers for a record past expires_at, whatever its status field says. poll_awaiting deletes the approval hash when it settles the entry.

Redis keys

approval:{uuid}         # HASH — approval state
approval:{source}_map   # HASH — external message id -> approval id

The requesting service writes and polls approval:{uuid}. The notify service owns approval:{source}_map. Bridges share only the key format — Discord and email do not know about each other.

Implementation

The park-and-poll flow (process_one(), poll_awaiting()), the static resolve_approval(), and the auto-tracking of the map all live in the shared OutboxService. A per-platform bridge only needs to:

  1. Return SendResult(ok=True, external_id=...) from its send().
  2. Call OutboxService.resolve_approval(pubsub, source, external_id, status, approver) from its ingest handler when a reaction or reply arrives (IngestService.try_resolve_approval() wraps this).

systemd services

Each bridge {name} is run as three systemd services: cowboy-{name}-ingest, cowboy-{name}-outbox, and cowboy-{name}-ping.

Configuration

Approval is configured per bridge under services.cowboy.bridges.<name>.approval (and equivalently on services.cowboy.pubsub.sources.<name>.approval):

services.cowboy.bridges.discord.approval = {
  required = false;       # hold outbound messages for manual approval
  notify = "discord";     # which outbox to send approval notifications to
  notify_channel = "";    # channel id within that outbox
  timeout = 3600;         # auto-reject after N seconds (0 = no timeout)
};

If required is true, notify_channel must be set. The Redis ACL enforcement option keeps the agent restricted to stream commands so it cannot touch the approval hashes directly.

Two bridges use this protocol for something other than a message. rebuild gates a system rebuild, and effects gates a declared action on the world (a git push, for instance) — see Gated Effects. For effects, approval.required is read-only true: there is no unapproved mode, and the approval is bound to a specific commit sha rather than to the request text, so what the operator approved is what runs, byte for byte.

Adding a new approval channel

To approve via something other than Discord:

  1. Have the new service’s outbox return external_id from send().
  2. Have its ingest call OutboxService.resolve_approval(...).
  3. Set approval.notify = "<service>" on the bridge that needs approval.

No changes to OutboxService or the requesting service are required.