CyberCLI

← All engines · Delivery

Delivery Dispatcher

stable

v1.0.0 · last updated 2026-06-06 · source: cybercli.delivery.dispatcher.Dispatcher

§1

What it does

Resolves the per-recipient routing matrix against a PolicyInput, calls each matching channel adapter once with idempotency + maintenance + rate-limit guards, and writes a ledger row plus an audit row for every attempt.

§2

Why you care

The dispatcher is the engine that turns the bridge's verdict into a notification actually arriving in your operator's pocket. Without it, the audit row says `approved_autonomous` and nothing fires. With it, the right people get paged at the right urgency through the channels they actually read, with idempotency that won't double-page them on a reprocess.

Routing is operator-configured through the per-CYCON channel matrix, not hardcoded. A buyer who runs Slack at CYCON 5 (peacetime) but escalates to Pushover + Slack at CYCON 2 (active threat) configures that matrix on the dashboard's /delivery page. The dispatcher reads the matrix per event and computes the plan deterministically — same input, same plan, no surprises.

Every dispatch attempt lands on the audit log. Whether it succeeded, was skipped for idempotency, was suppressed by a maintenance window, or was rate-limited — there's a row. That's how an operator answers 'why didn't the 3am page fire?' two days later without needing to be there when it happened.

§3

How it works

On each PolicyInput the dispatcher first asks the DeliveryPolicy for a list of DispatchPlans — one per (recipient, channel) tuple. The policy reads the per-CYCON routing matrix + the recipient roster from `[[delivery.recipients]]` config + the runtime overrides from the RoutingMatrixStore SQLite table.

For each plan the dispatcher runs a strict order of checks: (1) is the channel adapter registered for this install? (2) has this `(decision_id, channel, recipient_id)` tuple already succeeded? (idempotency ledger lookup) (3) is a maintenance window suppressing this (channel, CYCON) combination? (4) does the rate-limiter have budget for this channel? Only if all four pass does the dispatcher call the channel's `dispatch()` method.

On success the dispatcher writes a LedgerEntry to SQLite + an audit row with the channel, recipient, sovereignty_class, and the channel's response code. On retryable failure the dispatcher schedules a backoff retry under the per-channel retry policy. On unretryable failure the audit row carries the failure reason + the error class — the operator can replay forensically without the actual response body leaking sensitive content.

After the initial dispatch fires, the dispatcher hands the PolicyInput to the AckLoopEngine if CYCON is at-or-below the ack-enrollment threshold (default 2). That engine re-dispatches the same input at exponential intervals — 5min, 15min, 30min, 60min, 60min — until an operator acknowledges via dashboard or via the channel's native ack (PagerDuty incident close, Slack thread reaction, etc.).

§4

Configure & observe

CLI

$ cybercli channels list

Show every configured destination with channel type, current health, and the sovereignty class (LOCAL / BYO_EXTERNAL / CYBERCLI_HOSTED).

$ cybercli channels test <destination_id>

Synthesize a test PolicyInput and dispatch only to the named destination. Used to validate a Slack webhook URL or Pushover key without waiting for a real signal.

$ cybercli audit tail -n 20

Each dispatch attempt — success, skip, suppress — writes an audit row. Filter forensically by outcome to see what fired and what didn't.

Dashboard surfaces

/delivery

The routing matrix — per-CYCON × per-channel grid the operator edits to say which destinations fire at which urgency.

/channels

The destination roster. Add / edit / pause a channel here. The dispatcher reads the live state on every PolicyInput.

/notifications

Tails the dispatcher's ledger in real time. Shows which alerts fired, to whom, at what urgency, with delivery success / failure.

Audit row shape

Outcomes emitted
notification_sentnotification_failednotification_skipped_idempotentnotification_maintenance_suppressednotification_rate_limited
Parameters block

parameters.channel + parameters.recipient_id + parameters.sovereignty_class on every row. Success rows carry parameters.channel_response (status code + connector-side message id); failure rows carry parameters.error_class + a sanitized parameters.error_message. Maintenance / rate-limit rows carry the matching window or budget key.

§5

Status

Stable — the dispatcher has been on the v1 acceptance install since 2026-06-03 with 11 channel adapters wired (email / slack / pushover / webhook / ntfy / discord / teams / pagerduty / sms / voice / local_command). Idempotency, maintenance, and rate-limit guards are all covered by unit tests in `tests/delivery/`.

Known limit: only one destination per channel type is supported today (one Slack webhook, not two). Multi-destination-per-channel (route to #soc-alerts AND #oncall-pager) is a v1.3 item — already in the schema, just not in the dispatch loop yet.

Known limit: the AckLoop re-dispatch interval is fixed at 5/15/30/60/60 minutes. Per-channel custom intervals (Pushover every 2min vs. email every 30min) are a v1.x backlog item.

Shipped 2026-06-07 (API half): `cybercli.delivery.circuit_breaker` ships a per-channel three-state breaker (CLOSED / OPEN / HALF_OPEN). Operator knobs: `open_threshold` (default 5 consecutive failures) + `cool_off_seconds` (default 60). Monotonic clock so NTP / DST jumps don't drift the cool-off. Snapshot() + reset() methods drive the dashboard 'circuit state' widget + the 'force-close' button. The dispatcher integration that consults `should_dispatch(channel)` before every adapter attempt + reports the outcome via `record_success` / `record_failure` ships in v1.x.

How this engine talks to the rest