notifications: dispatch newly-opened findings to email and webhook channels (#60) - #100
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_deliveryrow per (channel, finding, scan) in thetransaction 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:
failedand stays, holding the error — so "what did we neversend?" is a SQL query, not a log search;
A
(channel, finding, scan)unique constraint makes enqueueing idempotent — the stalled-scan sweepre-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
column to
Findingcannot silently start exporting it. Redacted snippet only (ADR 0004); noanalyst notes or assignee.
timestamp.bodywhen the channel has a secret; the timestamp isinside the MAC, so replay has a bounded window.
editing the channel.
Docs
docs/notifications.md— when something is announced, the delivery/retry model, the full webhookpayload, 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 typeand the full suite pass.Closes #60
🤖 Generated with Claude Code