Skip to content

notifications: dispatch newly-opened findings to email and webhook channels (#60) - #100

Merged
richardmhope merged 1 commit into
mainfrom
m4-notification-dispatch
Jul 31, 2026
Merged

notifications: dispatch newly-opened findings to email and webhook channels (#60)#100
richardmhope merged 1 commit into
mainfrom
m4-notification-dispatch

Conversation

@richardmhope

Copy link
Copy Markdown
Contributor

The channel model and CRUD shipped with M3 (#59). This is the delivery half, and closes the
Notifications epic's remaining acceptance criteria.

Shape: a transactional outbox

Reconciliation writes one notification_delivery row per (channel, finding, scan) in the
transaction that finishes the scan
, and sends nothing. The maintenance loop — one replica, under
the advisory lock the scheduler already holds — picks up due rows and attempts them.

That is what turns "failures retried and logged, never lost silently" into a property:

  • a webhook that hangs for its full timeout delays an alert, not an engine's result submission;
  • a receiver that is down is retried with exponential backoff (60s, doubling);
  • after the ceiling the row goes failed and stays, holding the error — so "what did we never
    send?"
    is a SQL query, not a log search;
  • failures that time cannot fix (no URL, no relay configured, HTTP 401/403/400) skip the retries.

A (channel, finding, scan) unique constraint makes enqueueing idempotent — the stalled-scan sweep
re-finalizes scans routinely and must not re-announce the same secret every beat.

What gets announced

What this scan opened: first seen here, or seen again after having been resolved. Not every open
finding on every scan — an operator told weekly about the same secret stops reading the alerts, and
it is on the console either way. Suppressed findings are excluded; honouring a suppression in the UI
and mailing anyway would be worse than not having suppressions (ADR 0008).

Egress is treated as a boundary

  • Payload built from an explicit field list, never by serialising the ORM row, so adding a
    column to Finding cannot silently start exporting it. Redacted snippet only (ADR 0004); no
    analyst notes or assignee.
  • Signed with HMAC-SHA256 over timestamp.body when the channel has a secret; the timestamp is
    inside the MAC, so replay has a bounded window.
  • Redirects are not followed — a 302 would relocate where findings are sent without anyone
    editing the channel.
  • Response bodies never reach an error message or a log line.
  • Email is plain text: resource locators come from scanned systems.

Docs

docs/notifications.md — when something is announced, the delivery/retry model, the full webhook
payload, headers, a signature-verification snippet, receiver expectations, SMTP settings, and the
queries for operating it. docs/security.md § Notification egress extended to cover dispatch.

Tests

31 new tests: filter / suppression / disabled-channel exclusion, idempotent re-enqueue, delivery,
retry scheduling and backoff timing, exhaustion, permanent failure, a transport that crashes,
payload contents (including that the plaintext never appears), signature correctness, redirect
refusal, and SMTP end to end against an in-process server that actually speaks SMTP rather than
a mocked smtplib.

make lint, make type and the full suite pass.

Closes #60

🤖 Generated with Claude Code

…annels (#60)

The channel model and its CRUD shipped with M3 (#59); this is the delivery half.

**Shape: a transactional outbox.** Reconciliation writes one
`notification_delivery` row per (channel, finding, scan) in the transaction that
finishes the scan, and sends nothing. The maintenance loop — one replica, under
the advisory lock the scheduler already uses — picks up due rows and attempts
them. That is what makes "failures retried and logged, never lost silently" a
property rather than an intention:

* a webhook that hangs for its full timeout delays an alert, not an engine's
  result submission;
* a receiver that is down is retried with exponential backoff (60s, doubling);
* after the attempt ceiling the row goes `failed` **and stays**, holding the
  error that ended it, so "what did we never send?" is a query;
* failures time cannot fix — no URL, no relay configured, HTTP 401/403/400 —
  skip the retries, because five attempts only delay the operator finding out.

A `(channel, finding, scan)` unique constraint makes enqueueing idempotent. The
stalled-scan sweep re-finalizes scans routinely, and it must not re-announce the
same secret every beat.

**What gets announced** is what this scan opened: first seen here, or seen again
after being resolved. Not every open finding on every scan — an operator told
weekly about the same secret stops reading the alerts. Suppressed findings are
excluded: honouring a suppression in the console and mailing anyway would be
worse than not having suppressions (ADR 0008).

**Egress is treated as a boundary** (docs/security.md § Notification egress). The
payload is built from an explicit field list, never by serialising the ORM row,
so a new column on Finding cannot silently start being exported; it carries the
engine-redacted snippet (ADR 0004) and no analyst notes. Requests are signed
(HMAC-SHA256 over `timestamp.body`) when the channel has a secret, redirects are
not followed — a 302 would relocate where findings go without editing the
channel — and response bodies never reach a log line. Email is plain text
because resource locators come from scanned systems.

Payload, headers, signature verification and receiver expectations are documented
in `docs/notifications.md`.

Tested: filter/suppression/disabled-channel exclusion, idempotent re-enqueue,
delivery, retry scheduling, backoff timing, exhaustion, permanent failures, a
crashing transport, signature correctness, redirect refusal, and SMTP end to end
against an in-process server that actually speaks SMTP.

Closes #60

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@richardmhope
richardmhope merged commit a52179a into main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email/SMTP & webhook dispatch

1 participant