CyberCLI

← All engines · Delivery

AckLoopEngine (CYCON 2/1 repeat-until-ack)

stable

v1.0.0 · last updated 2026-06-06 · source: cybercli.delivery.ack_loop.AckLoopEngine

§1

What it does

Re-fires high-CYCON notifications on a 5/15/30/60/60 minute cadence until the operator acknowledges in the dashboard or in the channel itself — with idempotency + de-escalation guards that prevent runaway pages.

§2

Why you care

The AckLoop is the engine that makes 'alert fatigue' a solvable problem instead of an excuse. For CYCON 2 (active threat) and CYCON 1 (maximum defense) events, one notification isn't enough — the operator might be in a different timezone, on a flight, or just not looking. The loop keeps re-firing the same notification at growing intervals until someone proves they saw it.

Other AI security products either fire-and-forget (Sentinel) or expect you to wire your own pager service (Wazuh, Panther). CyberCLI ships the repeat-until-ack as a first-class engine with the same audit-row honesty as every other notification: every re-dispatch lands on the audit log with the attempt number + the next-fire time. Forensically you can replay 'how many times did we page chris before he ack'd' six months later.

The loop is also CYCON-aware in the right direction. If the engine downgrades CYCON during a re-fire cycle (the threat passed, the posture relaxed), the loop automatically clears every enrolled signal above the new CYCON floor. You don't get paged at 3am for a CYCON 2 event that the engine downgraded to CYCON 4 at 2:30am.

§3

How it works

When the dispatcher fires the initial notification for a signal whose CYCON is at-or-below the enrollment threshold (default 2 = ENGAGED), it hands the PolicyInput to the AckLoopEngine. The engine writes a row in the `delivery_pending_acks` SQLite table with the signal_id, the CYCON at enroll, the attempt index (starts at 1), the next-fire time, and the original PolicyInput body for replay.

On every tick (the runtime drives it on a 30-second cadence), the engine queries for rows whose `next_fire_at` has elapsed. For each, it re-runs the dispatcher's dispatch() with the stored PolicyInput, bumps the attempt index, and computes the next fire time on a fixed schedule: 5min after attempt 1, 15min after attempt 2, 30min after attempt 3, 60min after attempts 4 and 5+. The schedule caps at attempt 6 — if six re-fires aren't enough, the loop gives up and writes a `repeat_giveup` audit row.

Acknowledgements come in two shapes. The operator clicks 'Ack' on the dashboard's /actions page or on a PagerDuty incident, and the AckLoopEngine sees the signal_id appear in the audit log under outcome=`notification_acknowledged`; it deletes the corresponding pending-ack row. Or the engine downgrades CYCON to peacetime and `clear_above_cycon(new_floor)` deletes every pending row whose `cycon_at_enroll` was below the new floor.

Audit attribution is honest. Every re-fire writes a `notification_repeat_fired` row carrying the attempt index + the cumulative wall-clock elapsed since the initial fire. An operator reading the audit log can answer 'we paged chris four times across 75 minutes, he ack'd at the fourth fire' without joining tables — it's all on one decision_id.

§4

Configure & observe

CLI

$ cybercli audit tail -n 30

Filter for outcome=notification_repeat_fired to see the loop firing. Each row carries attempt_index + next_fire_at.

Dashboard surfaces

/actions

The Ack queue. Click 'Acknowledge' on a row and the loop stops re-firing for that signal.

/audit (filter outcome=notification_*)

Forensic replay of every repeat-until-ack cycle — see attempt counts, cumulative elapsed, and which ack source (dashboard / channel-native / cycon-downgrade) finally cleared the loop.

Audit row shape

Outcomes emitted
notification_repeat_firednotification_acknowledgednotification_repeat_giveupnotification_repeat_cleared_by_cycon_downgrade
Parameters block

parameters.signal_id + parameters.attempt_index + parameters.cycon_at_enroll + parameters.next_fire_at on every row. ACKNOWLEDGED rows additionally carry parameters.ack_source (dashboard / pagerduty / slack / cycon-downgrade) so a replay can answer 'how did we find out chris was looking.'

§5

Status

Stable — the 5/15/30/60/60 schedule has been frozen since 2026-05-08 and held through 8 stress-test runs with synthetic 1000-signal floods. The cap-at-6 give-up was added in commit 4f8e2a3 after a v0.4 incident where a stuck pager kept re-firing the same row.

Shipped 2026-06-07 (API half): AckLoopConfig.per_channel_schedules accepts an optional `{channel_value: (delay1, delay2, ...)}` dict. New `_next_fire_delay_seconds_for_channel(attempt_index, channel, overrides)` honors the per-channel override; channels with no entry fall through to the global 5/15/30/60/60 schedule. Default None preserves pre-r17 behavior exactly. The runtime change that tracks which channel each PendingAck was enrolled for + passes the channel hint into the tick-time lookup ships in v1.x.

Shipped 2026-06-07: `AckLoopStore.stats(now=None)` returns a single-round-trip SQL aggregate of the pending-ack table — `total`, `due_now`, `overdue_30s`, `overdue_5m`, `by_cycon` ({cycon: count}), `by_attempt_index` ({attempt: count}), and `oldest_enrolled_at` (ISO string or None). The dashboard's 'ack loop posture' widget pulls this snapshot directly instead of reading every row + reducing in Python — meaningful at scale and a lot less code in the route handler.

Verified 2026-06-07 (not a gap): the runtime already reads `[delivery] ack_loop_tick_seconds` from config.toml (runtime.py:1792). Operators tune this directly today; default is 60s. The earlier 'fixed cadence' claim in this doc was incorrect — backlog item removed.

How this engine talks to the rest

Upstream · feeds this engine
Downstream · this engine feeds