Skip to content

alerting: satd-alert crate + webhook dispatcher, absorbing reorgwebhook - #482

Merged
bkeroack merged 11 commits into
masterfrom
feature/a3-webhook-dispatcher
Aug 2, 2026
Merged

alerting: satd-alert crate + webhook dispatcher, absorbing reorgwebhook#482
bkeroack merged 11 commits into
masterfrom
feature/a3-webhook-dispatcher

Conversation

@bkeroack

Copy link
Copy Markdown
Contributor

PR 3 of the A3 alerting stack (SATD_ALERTING_DESIGN.md §4). Stacked on #481 (which is stacked on #480) — merge bottom-up.

PR 2 made the node detect its own problems. This delivers them somewhere.

What

alertfile=<path> configures any number of signed HTTP hooks, each filtered by category, status kind, and severity floor:

version = 1

[[webhook]]
id = "pager"
url = "https://alerts.example/satd"
secret = "a-long-random-string"
categories = ["status"]
kinds = ["tip_stall", "disk_low"]
min_severity = "warning"

New satd-alert crate holds the pure half — alertfile parsing/validation/permissions, the delivery contract (headers + HMAC), and retry classification — so every rule is testable without a socket and the signature scheme is pinned by golden vectors a third-party receiver can check itself against. reqwest and the dispatcher task stay in the binary. Same split, same reasoning, as satd-auth.

Decisions worth reviewing

  • One wire schema, not two. The POST body is byte-identical to the JSON a WebSocket subscriber receives for the same event. Delivery metadata rides in headers — putting the attempt counter or hook id in the body would change the bytes between retries of one event, breaking both signing and receiver-side dedup.
  • A permanent 4xx is skipped, not retried forever. A receiver answering 404 must not pin the head of the queue and convert every later event into an overflow drop. Transient failures (5xx/408/429/timeout/connect) retry indefinitely with backoff to a 5-minute ceiling.
  • A gap is never silent. Both drop paths — full hook queue, lagged broadcast receiver — emit a synthesized lagged body carrying the count and a resume cursor ahead of the next delivery.
  • At-least-once for confirmed chain events across a restart, via a per-hook cursor in the chainstate metadata CF plus a startup replay through the same machinery the streaming carriers use (same 10k clamp, same clamp signal). Health events come back by re-evaluation; mempool stays best-effort — the contract the bus itself offers.
  • Config follows the authfile model: path restart-only, contents re-read on every SIGHUP, 0600 enforced from the open handle (not the path — no TOCTOU window). A parse error keeps the last-good hook set, because alerting that silently stopped after a typo is the worse failure. Validation is recognize-and-reject throughout: unknown category, typo'd kind, duplicate id, missing secret are all errors rather than a rule that quietly matches nothing.
  • reorgwebhook= is absorbed, not replaced. Its payload stays the shipped ReorgRecord JSON byte for byteChainEvent::Reorg carries neither depth, fork_height, nor the disconnected/reconnected lists, so re-shaping it would have silently broken deployed receivers. Routing it through this module also moves that outbound HTTP off the consensus runtime, where it should never have been (design D7).
  • Plaintext http:// is gated to loopback/RFC1918 unless allow_insecure_http = true. Bodies carry chain data rather than secrets, but signed-then-cleartext is still a footgun.
  • satd-alert depends on node (unlike satd-auth, which depends on nothing). Deliberate: the health taxonomy gets exactly one definition, so an alertfile's kinds/categories/min_severity are validated against the live enum rather than a copy that could drift. The no-async/no-HTTP constraint that actually matters is kept.

Acceptance (design §10 PR 3)

  • §6.1/§6.2 delivery semantics — E2E: signed status delivery verified exactly as a third party would (HMAC over the raw body), retry-until-recovery proven via X-Satd-Attempt, permanent 4xx not wedging the queue behind it.
  • §6.4 signature golden vectors in satd-alert (reproduced in the docs; the relay will re-assert them).
  • §6.5 config: perms/parse rejection matrix, SIGHUP add/edit/remove, HANDLER_RELOAD_KEYS coverage test.
  • No dispatcher configured ⇒ zero new tasks and no satd_alertwebhook_* on /metrics (unit-asserted, same invisibility property the policy metrics have).
  • clippy --workspace --all-targets --all-features clean; full suite green except the 10 pre-existing test_address_index_backfill_* disk-guard failures on this machine.

Docs: Operator Manual gains an Alert webhooks section (format, delivery contract, failure behavior) and the config reference entry. The normative docs/api/webhooks.md spec + the stalled-endpoint latency bench are PR 5, per the design's split.

🤖 Generated with Claude Code

https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH

@bkeroack

Copy link
Copy Markdown
Contributor Author

Round-1 review fixes (both from the same pass).

Medium — SSRF blast radius. Both delivery clients built with reqwest's default redirect policy, which follows them. A followed 30x would move the POST — body, X-Satd-Signature, hook identity — to a host that never passed validate_url, and the useful targets are the ones an operator cannot see from outside: a metadata endpoint, an RFC1918 admin port, the node's own RPC. Both clients now use Policy::none(), and a 3xx classifies as a permanent drop so the misconfiguration shows up in the log rather than as silent non-delivery. E2E: the redirect target is a second mock receiver that must never be touched.

Low–Medium — cursor parked on permanent drop. A permanent 4xx skipped the event without advancing the resume position, so a receiver that hard-rejects one body shape made every reload and restart rebuild and re-queue the same refused span forever. The event is lost either way; the only question is whether the hook makes progress past it. The skip now persists the cursor, still counted and logged. E2E counts request attempts across a reload — counting accepted deliveries would have passed either way, since a replayed block gets refused again.

Both tests verified to fail without their fix.

@bkeroack
bkeroack force-pushed the feature/a3-health-detectors branch from 59a89f4 to 8486e10 Compare July 25, 2026 15:46
@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch 4 times, most recently from aa6573d to 6addf3e Compare July 30, 2026 02:05
@bkeroack
bkeroack force-pushed the feature/a3-health-detectors branch from 04cb6da to 3f67877 Compare July 30, 2026 19:10
@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch 3 times, most recently from 050aaf8 to 1f0db76 Compare July 30, 2026 19:56
@bkeroack

Copy link
Copy Markdown
Contributor Author

Deep review — findings (#482 + #484)

Independent review. Marked [verified] = I re-checked against the code myself; the rest are reviewer-reported.

Clean and genuinely well done: outbound-request safety (Policy::none() so no redirect following, url::Url scheme allowlist that handles @-last and \-authority tricks, 10 s per-attempt timeout, response body never read, bounded 1024 queue, serial per-hook delivery); consensus isolation (ReorgLog::record try_sends, both dispatchers on the API runtime, no lock held across an await); HMAC construction (LF-delimited signing string with every pre-body field constrained to an LF-free charset and the body last — unambiguous; independently computed golden vectors; secrets kept out of Debug, TOML error snippets, and delivery logs); default-off.

1. The legacy reorg path logs the webhook URL verbatim — MEDIUM [verified]

satd/src/alert.rs — the alertfile path applies e.without_url() with a comment explaining exactly why ("a webhook URL is frequently the credential itself (Slack, Discord, PagerDuty)"). The reorgwebhook path ~300 lines later logs error = %e without it. With reorgwebhook=https://hooks.slack.com/services/T00/B00/<secret>, any endpoint blip puts the full credential-equivalent URL into stdout → journald → the operator's log shipper, three times per record.

2. signed_at is stamped at dequeue, so the freshness window bounds only retry span — MEDIUM

The spec mandates receivers reject anything outside 600 s, and the signed timestamp is the only staleness signal the documented receiver algorithm checks. But signed_at is taken after rx.recv(). With a hard-down endpoint, delivery is serial and each event burns ~10 min of retries, so a tip_stall raised at T+0 can be popped at T+2h, signed with now, pass the receiver's freshness check, and page on-call for a condition that raised and cleared two hours earlier.

3. "Retries continue indefinitely" is false — MEDIUM [verified]

Delivery is abandoned once unix_secs() - signed_at > MAX_TIMESTAMP_SKEW_SECS (600), reached around attempt 10–11. Contradicted in three places: docs/api/webhooks.md "jittered, and continue indefinitely", the private-CA note "will therefore fail verification and be retried forever", and docs/manual/src/observability.md "1 s doubling to a 5-minute ceiling, indefinitely". An operator reading that concludes a 15-minute relay redeploy is covered and doesn't alert on satd_alertwebhook_dropped_total.

4. CHANGELOG and release notes promise a lagged gap notice that does not exist — MEDIUM [verified]

CHANGELOG.md and 0.5.0-pre.md describe "a bounded queue whose overflow is reported in-band as a lagged event rather than silently" and a skip that "advances the resume position". Nothing is persisted and nothing is synthesized — enqueue drops silently on Full, docs/api/webhooks.md §5.3 says "There is no in-band notice", and the branch's own E2E asserts the absence ("a lagged body was delivered; gap accounting was removed with the cursor"). Stale claims from the pre-revision design. An integrator who treats absence of lagged as proof of no gap silently misses every drop.

5. A dead delivery task is carried across every SIGHUP — LOW

The reload keeps an existing RunningHook whenever r.config == *hook, with no liveness check on r.tx. If webhook_client() failed at startup for one hook, its channel is closed forever; every SIGHUP re-adopts the closed sender and the hook is permanently dark, recoverable only by restart or editing the stanza to force a mismatch. A !r.tx.is_closed() in the match guard fixes it.

6. is_local_host exempts IPv4 link-local, including 169.254.169.254 — LOW

satd-alert/src/config.rsv4.is_link_local() waives the plaintext gate, so http://169.254.169.254/… is accepted with no allow_insecure_http. Both the spec and the manual say the waiver is for "loopback and RFC1918" only, and the IPv6 arm is stricter (is_unique_local() only, so fe80:: does require the opt-in). Low impact — the alertfile is operator-controlled — but it's the one address the module's own comments name as the interesting target.

7. alertfile= path changes are silently ignored on SIGHUP — LOW

alertfile is in HANDLER_RELOAD_KEYS only. authfile, whose model this copies, has both a handler and a restart! spec. AlertReloader binds self.path at construction, so changing alertfile= and SIGHUPing reloads the old file and logs success against it. That's the accepted-but-ignored-config failure the parser's whole posture exists to prevent.

8. The Operator Manual still says reorgwebhook= users need no edits — MEDIUM [verified]

docs/manual/src/observability.md: "existing receivers need no edits." #484 correctly documents the redirect-following break in the CHANGELOG and release notes, but not in the published manual. An operator whose reorg endpoint answers 301/302 (an http→https proxy hop, a trailing-slash redirect) now silently loses every reorg record, and the canonical doc says nothing changed.

@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch from 2948bcd to 001966e Compare August 1, 2026 04:06
@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch from 001966e to 1cd27d0 Compare August 1, 2026 14:09
@bkeroack
bkeroack force-pushed the feature/a3-health-detectors branch from 5a978b9 to 7eb8351 Compare August 1, 2026 17:01
@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch from 1cd27d0 to beceb29 Compare August 1, 2026 17:01
@bkeroack

bkeroack commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Tier 1 + Tier 2 fixes (beceb292 and its lineage)

Finding 2 — signed_at stamped at dequeue (MEDIUM). Fixed. The timestamp is now taken once in the fan-in, when the event leaves the bus, and carried on Delivery::Event::queued_at.

Delivery is serial per hook and a hard-down endpoint burns its full retry budget on each event in turn, so a tip_stall raised at T+0 could be popped at T+2h, signed with now, pass the receiver's 600 s check, and page on-call for a condition that raised and cleared two hours earlier. The signed timestamp is the only staleness signal the documented receiver algorithm has, so this defeated it entirely. One clock read fans out to every hook, so an event is never stale for one receiver and fresh for another.

Finding 5 — dead delivery task re-adopted across every SIGHUP (LOW, but it had a second half). Fixed, and the obvious one-line version would not have worked.

Adding !r.tx.is_closed() to the match guard is correct but unreachable on the path that matters: apply() short-circuits when the alertfile is unchanged, which is the common case for a SIGHUP — an operator reloads precisely because something is wrong. So the short-circuit now makes an exception for a closed sender. Without that, "edit the stanza to force a mismatch" would have remained the only recovery, which is exactly what the finding described.

Lock ordering checked: last_appliedrunning is the only nesting and nothing takes them the other way.

Findings 3, 4, 8 — docs asserting things the code does not do. Fixed across this PR and #484:

  • "Retries continue indefinitely" (3 places) — they stop once a delivery ages past the 600 s freshness window, around attempt 10–11. The consequence that was actually missing is now stated: a receiver down more than ~10 minutes loses the events raised during the outage. An operator reading "indefinitely" would conclude the opposite and not alert on satd_alertwebhook_dropped_total.
  • The phantom lagged gap notice — nothing synthesizes one and this branch's own E2E asserts its absence. Removed from CHANGELOG and release notes, replaced with the truth plus "do not read the absence of a notice as the absence of a gap".
  • "advances the resume position" — also stale; the per-hook cursor was deleted with the durability machinery. Caught during the rebase and removed.
  • The manual's "existing receivers need no edits" — now carries the redirect-following break explicitly, since a reorgwebhook endpoint answering 301/302 silently loses every record.

Deferred (Tier 3)

  • Finding 6 — is_local_host exempts 169.254.169.254. The alertfile is operator-controlled and mode-0600, so this is a code-vs-its-own-docs divergence rather than something an attacker reaches. One-line fix (drop is_link_local() from the v4 arm, matching the stricter v6 arm); worth doing, not urgent.
  • Finding 7 — alertfile= path changes silently ignored on SIGHUP. The fix is a restart! spec alongside the handler, the model authfile follows. alertfile is genuinely both hot (contents) and restart-required (path), and expressing that needs a small addition to FieldSpec, which every other key shares. Out of scope for a review round, but this is the accept-and-ignore failure the config parser exists to prevent, so it should not sit long.

The dead-task test was run against the previous behavior and fails there.

bkeroack and others added 4 commits August 1, 2026 18:02
PR 3 of the A3 alerting stack (SATD_ALERTING_DESIGN.md §4). PR 2 made the
node detect its own problems; this delivers them somewhere.

`alertfile=<path>` configures any number of signed HTTP hooks, each filtered
by category, status kind, and severity floor. The pure half — parsing,
validation, permissions, the delivery contract, retry classification — lives
in a new `satd-alert` crate so every rule is testable without a socket and
the signature scheme can be pinned by golden vectors a third-party receiver
checks itself against. `reqwest` and the dispatcher task stay in the binary.
The split, and the reason for it, mirror `satd-auth`.

Design points:

- One wire schema, not two. The POST body is byte-identical to the JSON a
  WebSocket subscriber receives for the same event, so a receiver parses both
  with one code path. Delivery metadata rides in headers — putting the
  attempt counter or hook id in the body would change the bytes between
  retries of one event, breaking both signing and receiver-side dedup.

- Honest failure behavior. Serial and in-order per hook; exponential backoff
  to a 5-minute ceiling on 5xx/408/429/timeouts; any other 4xx counted and
  skipped, because a receiver answering 404 forever must not pin the head of
  the queue and convert every later event into an overflow drop. A bounded
  queue that overflows emits a `lagged` body carrying the count and a resume
  cursor ahead of the next delivery: a gap is never silent.

- At-least-once for confirmed chain events across a restart, via a per-hook
  cursor in the chainstate metadata CF and a startup replay through the same
  machinery the streaming carriers use (same 10k clamp, same clamp signal).
  Health events come back by re-evaluation; mempool stays best-effort — the
  contract the bus itself offers.

- Config follows the authfile model: path restart-only, contents re-read on
  every SIGHUP, 0600 enforced from the open handle. A parse error keeps the
  last-good hook set — alerting that silently stopped after a typo is the
  worse failure. Validation is recognize-and-reject throughout: an unknown
  category, a typo'd kind, a duplicate id, or a missing secret is an error,
  never a rule that quietly matches nothing.

- `reorgwebhook=` is absorbed, not replaced. Its payload stays the shipped
  ReorgRecord JSON byte for byte — ChainEvent::Reorg carries neither depth,
  fork_height, nor the disconnected/reconnected lists, so re-shaping it would
  have silently broken deployed receivers. Routing it through this module
  also moves that outbound HTTP off the consensus runtime, where it should
  never have been (D7).

Per-hook counters (`satd_alertwebhook_*`) render only when a hook is
configured, so an unconfigured node's /metrics page is unchanged. E2E covers
a signed status delivery verified as a third party would, retry-until-recovery
via the attempt counter, and a permanent 4xx not wedging the queue behind it.
Round-3 review fixes folded in. Two of these change the wire contract; both are
pre-publication, which is the cheapest moment to change them.

- **Signature now covers the delivery metadata, not just the body (v2).** The
  contract tells receivers to deduplicate on `X-Satd-Delivery`, but v1 signed
  the body alone, leaving that header unauthenticated — and its value is fully
  predictable from any single observed delivery, since `seq` is dense. Anyone
  holding one valid (body, signature) pair could replay it under forged future
  ids, filling a receiver's dedup cache so the genuine alerts bearing those ids
  were discarded on arrival, while satd counted them delivered and advanced its
  cursor. The operator's pager simply never fires and nothing logs a problem.
  v2 signs "2\n<timestamp>\n<delivery-id>\n<hook-id>\n<body>" and adds
  `X-Satd-Timestamp`, which also bounds replay of the capture itself. Golden
  vectors computed independently rather than captured from this implementation.
  The legacy `-reorgwebhook` alias stays on v1 byte-for-byte — it shipped, and
  deployed receivers verify exactly that; the version header distinguishes them.

- **Catch-up replay gave every event the same idempotency key.** Replayed
  envelopes are stamped `seq: 0` by the replay builder, and the delivery id was
  minted from that stamp — so a node down for 100 blocks replayed 100 events
  that a conforming receiver (including our own relay) collapses into one. The
  durability feature was delivering 1% of what it advertises, silently. Same
  collision swallowed every gap notice after the first, so "a gap is never
  silent" held exactly once per process. Synthesized deliveries now get their
  own `-r<seq>` id space, disjoint from the bus and watch spaces.

- **No IBD gate.** The dispatcher starts ~600 lines before P2P, so a fresh node
  with an alertfile POSTed its entire initial sync — one delivery per historical
  block. The manual promises the opposite. Suppress the firehose during IBD;
  status events stay exempt, since "this node is unhealthy" is as true during a
  sync.

- **A SIGHUP unrelated to the alertfile destroyed queued deliveries.** `apply()`
  ran on every SIGHUP whatever key was edited, and retiring a generation drops
  its queue. Chain events recover via catch-up, but status events have no replay
  and the detectors are edge-triggered against a `HealthState` that outlives the
  reload — so a `disk_low` in retry backoff was lost permanently and never
  re-raised. An unchanged alertfile is now a no-op.

- **Catch-up discarded the snapshot->live boundary dedup** that both shipped
  carriers keep, so a block connecting during the replay window was delivered
  twice — with two different delivery ids, which a receiver cannot collapse.

- `metrics.retain` was built from alertfile ids only, evicting the legacy reorg
  hook's counters at startup and leaving reorg delivery permanently unobservable
  on any node configuring both.

- The fan-in serialized and minted an id for every envelope *before* checking
  whether any hook wanted it — including `BlockTweaks`, hundreds of KB per
  block, which is refused at parse and can never match.

- A removed hook's durable cursor was never deleted, so reusing a short id
  (`pager`, `ops`) greeted a brand-new endpoint with its predecessor's history.

- Catch-up logged the 10k-block clamp but not the 1024-deep queue truncation,
  which is the binding limit for any realistic outage; the success line then
  reported the pre-truncation count. Also an off-by-one in the skipped count.

- Webhook metrics emitted a `# HELP`/`# TYPE` pair per hook rather than per
  family — well-formed with one hook, invalid with two, and a strict parser
  rejects the entire page.

- `persist_cursor` had no monotonicity guard, so an in-flight delivery from a
  retired generation could rewind the durable cursor past what its successor had
  already advanced.

- A closed hook channel was a silent no-op: a dead delivery task left the hook
  looking healthy on /metrics while delivering nothing, forever.

- `is_local_target` split the authority on the last colon, so
  `http://127.0.0.1:8332@evil.example/` parsed as loopback and skipped the
  plaintext-HTTP acknowledgement while sending cleartext to a public host.

- `Hook`'s derived `Debug` rendered `secret` in full; one `debug!(?hook)` added
  later would put a signing key in the log.

- The legacy reorg alias retried only on the new `Disposition` classification,
  turning what was "retry any non-2xx three times" into a one-shot drop for 4xx
  on a flag operators already depend on. Restored. (Redirect-following is
  deliberately *not* restored — it is an SSRF vector for a signed request.)

Each behavioral fix has a test verified to fail without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH
Round 4 of external review, scoped to the ~966 lines this branch added
during round 3 — code written as fixes and never itself reviewed.

The IBD gate inverted the feature's purpose. `is_initial_block_download()`
is not a sync flag; it is `tip.header.time + 86400 < now`. A node whose tip
stops advancing re-enters "IBD" a day later, so the entire alert path went
dark during exactly the incident chain hooks exist to report — and the
suppression was silent: no `gap.dropped`, no `Lagged`, and the first
post-gate delivery then advanced the durable cursor across the whole
suppressed span, making it unrecoverable on the next restart while the
module claimed at-least-once. `Heartbeat` was not exempt either, so a
multi-day sync made an external dead-man's switch declare a healthy node
dead. Now latched on first leaving IBD, heartbeat-exempt, and suppressed
events are counted as a gap so the receiver is told. The latch also keeps a
per-event block-index lookup off the fan-in task once it fires.

`catch_up` computed `count as u64 - overflowed` from a *shared* counter:
`metrics.hook(id)` is process-lived and `apply()` spawns the new generation
before retiring the old, so a retired generation still shedding events could
drive the subtraction negative — a panic under debug_assertions, a wrap to
1.8e19 in release. The task is spawned bare with no supervisor, so that
killed the dispatcher, all hooks, for the life of the process. `enqueue` now
returns whether it queued and callers count locally.

`Disposition::Drop` was the one drop path that never set `gap.dropped`,
and it is the one most likely to fire on a routine misconfiguration — a 3xx
from an `http://` URL behind a TLS-terminating proxy, or a 401 from a
rotated secret or a delivery that aged past the receiver's freshness window
mid-retry. Combined with the cursor advancing past it, an hour of chain
history could be discarded with nothing said on either end.

The replay dedup map held every replayed height, but the replay builds up to
10,000 events into a queue holding 1024. The overflowing tail is exactly the
span still buffered on the broadcast, so those blocks' live copies were then
suppressed as "already replayed" — dropped twice. Now restricted to heights
actually queued.

Replayed blocks take a delivery id derived from their height
(`block_delivery_id`, `b<height>`). Restart and reload-overlap replays are
the only duplicates this design actually produces, and every counter-minted
id embeds a random per-process `instance_id` — so "deduplicate on
X-Satd-Delivery" was unimplementable for the only case that needs it.

Also: cursor GC now reconciles against the stored keyspace rather than the
previous generation, so removing a hook by editing the file and *restarting*
— the ordinary way — no longer leaks its cursor forever (new
`Store::list_alert_cursor_keys`); gap state is process-lived, so a reload no
longer discards a pending `Lagged`; a gap notice is flushed on a timer, so
it cannot wait forever on a hook that went quiet; the resume anchor is
seeded from the durable cursor rather than advertising height 0; and the
"delivery task is gone" warning is logged once per generation instead of
once per event, which on a busy mempool was thousands of lines a second
filling the disk `disk_low` watches.

Security: the userinfo SSRF fix was bypassable with a backslash.
`http://evil.example\@127.0.0.1/hook` — the WHATWG parser treats `\` as a
path separator for special schemes, so reqwest resolves `evil.example` while
`is_local_target` saw a loopback address, waived `allow_insecure_http`, and
posted the signed body in cleartext to the attacker's host.

The Operator Manual still documented the v1 body-only signature and version
1, with no timestamp header — anyone writing a receiver from it, which is
most of them, would have rejected 100% of deliveries. Rewritten for v2 with
the verification steps, the freshness requirement, and the delivery-id
namespaces.

Tests: the SIGHUP no-op fix had no regression test at all — reverting it
left the whole suite green. Added one that keeps a status event in retry
backoff across an unrelated reload. The retry test now pins the
sign-once-per-event invariant across attempts (it previously asserted only
on the accepted attempt, so moving the signing back inside the retry loop
passed unchanged). Two tests no longer depend on the host's free space: they
drive the threshold to a known-cleared state first, through the conf file,
since a CLI value would win over every later SIGHUP. Each new guard verified
to fail against the code it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH
The assertion read `dropped_count` off the *first* `lagged` notice and
required it to be 2. That held only because the two refusals happened to
land inside a single gap-flush window and coalesce.

Gap accounting also flushes on a timer, so a slower machine ticks between
the two rejections and emits two notices of 1 rather than one of 2. The
CI runner does exactly that: `wait_for` returned the first notice and the
assertion failed on `dropped_count: 1`. Nothing was wrong with the
dispatcher — announcing the loss promptly rather than batching it is the
better behaviour, and the test was pinning down a race it has no stake in.

Sum across every `lagged` notice within a deadline instead. The property
under test is that a permanently-refused span is announced at all, since
the cursor advances past it and the receiver can never go back for it;
how the count is split across notices is not part of that.

Negative control: disabling the gap attribution at the permanent-rejection
site fails the test with "got 0 across 0 notice(s)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH
bkeroack and others added 7 commits August 1, 2026 18:02
Implements the D6 revision. The dispatcher had a cursor-driven startup
catch-up that rebuilt and re-delivered the missed span. It is gone. The
per-hook cursor stays, as a resume *marker*: read once at startup, compared
against the tip, reported as one `Lagged`, then advanced past the span.

Deleted with it: the `b<height>` delivery-id namespace, the replay-to-live
boundary dedup, and the catch-up clamp. `block_delivery_id` was keyed on
height alone, so a block and its post-reorg replacement minted the same
`X-Satd-Delivery` and a conforming receiver discarded the replacement — the
one event class the feature exists to report. Its two tests could not have
caught that: one asserted a pure function is pure, the other varied only
the counter and never block identity.

Gap counts are now conserved. `flush_gap` retired the count the moment a
notice was *queued*, so a reload that retires the generation destroyed the
queue and the count with it — leaving the process-lived `GapState`, which
exists precisely to survive a reload, reading zero. The count now travels
on the `Delivery` and is only retired on a 2xx; teardown drains the queue
and hands weights back, including one already in hand mid-backoff. A
permanent rejection re-adds what the delivery carried instead of adding 1,
so rejecting a `Lagged` carrying 500 no longer shrinks it to 1.

The IBD gate is scoped to chain events, which is what actually closes the
restart hole. `is_initial_block_download()` is tip age, not a sync flag, so
a node restarted while already wedged reads as syncing from its first event
and never latches — and the old gate then suppressed *everything*: status,
mempool, and watch matches, which have no replay to recover them. A stalled
node is not producing chain events, so gating only those costs nothing in
that state and still stops the firehose it was written for.

Also: the gap map is pruned on reload alongside `metrics.retain` and the
cursor GC, so a re-added hook id no longer inherits its predecessor's drop
count; cursors are keyed on id + URL hash so repointing starts clean; and
`InMemoryStore` implements the four alert-cursor methods, without which the
trait defaults (`Ok(vec![])`, `Err`) make every cursor and GC test pass
vacuously.

E2E `catch_up_replay_gives_every_event_a_distinct_delivery_id` is replaced
by `a_missed_span_is_announced_as_a_gap_and_never_replayed`, negative
control verified. Workspace clippy clean; node, satd and satd-alert suites
green.
Two P0s in the SIGHUP handover, both of which silently destroy status
events — the one category with no replay behind it.

The bus subscription was taken inside the spawned fan-in, so it did not
exist until the executor first polled that task. Everything published
between retiring the outgoing generation and that first poll reached
nobody: not delayed, gone. The detectors are edge-triggered against a
HealthState that outlives the reload, so a disk_low landing in the window
is never re-raised and the page never arrives. apply() now subscribes
synchronously, before anything is retired, and hands the receiver to the
task. Both generations are briefly subscribed, which is the safe
direction: a bus delivery id is node-instance-<the event's own seq>, so
they mint the same id and a receiver deduplicating on X-Satd-Delivery
collapses them.

A reload also rebuilt every hook's delivery task, discarding the queue and
retry backoff of hooks the operator never touched. The unchanged-file
early return covered the case where nothing changed at all; editing one
stanza took every other hook's in-flight event down with it. The reloader
now owns the delivery tasks and reconciles them per hook: a stanza that
compares equal keeps its task, an edited or removed one is retired (and
hands its undelivered count back to the process-lived GapState, so the
hole is announced rather than swallowed). A carried-over hook also skips
the startup gap announcement — its queue survived, so the span between its
cursor and the tip is a backlog about to be delivered, not a hole.

Stop's second channel is now per-task rather than per-generation, and a
dropped sender counts as stopped: watch::Receiver::changed() returns Err
forever once the sender is gone, which would have spun the loop at 100%
CPU the moment a stop sender was dropped without sending.

Four tests, each verified to fail against the pre-fix code: the
subscriber-count handover invariant, hook carry-over and its mirror for an
edited hook, and an e2e case where editing one hook must not destroy
another's queued disk_low.
The manual, changelog and release notes still described the replay
machinery that D6 removed: "at-least-once ... on startup the hook replays
what it missed", and a `b<height>` delivery-id space for replayed blocks
that no longer exists. Both now say what the code does — at-most-once with
every gap announced, and a resume marker rather than a replay cursor —
and point at RescanBlocks or the JSON-RPC history calls for operators who
actually want the missing span.

Also documents the reload handover: an untouched hook keeps its delivery
task, so editing one stanza no longer discards another hook's pending page.
Scope correction per the amended design D6. Webhooks are the basic way to
automate off chain and mempool events; the streaming API is the canonical
integration surface and already does resumable consumption properly. Every
durability feature here was a worse restatement of that.

Gone: the durable resume cursor and with it the Store trait extension
(read/write/delete/list_alert_cursor_keys) across all four backends, the
cursor GC pass, URL-derived key rebinding, the fixed 24-byte cursor codec,
and per-height write throttling. Gone with it: GapState, gap_weight
conservation through overflow and permanent rejection, drain_owed,
announce_gap, flush_gap, the 15s flush timer, the synthesized delivery-id
space, and the Lagged body. A drop is now a counter and a log line.

The Lagged filter arm went too. lagged_event is built per-connection by the
streaming carriers and never published to the shared bus, so the dispatcher
could not receive one — the arm was unreachable and its test vacuous.

satd/src/alert.rs 1601 → 1142 lines; node/src/storage/ back to master.
The e2e suite asserts the new contract directly: a hook that was down
resumes at the live head, re-sends nothing, and synthesizes no notice.
…ls into logs

Deep-review fixes for the dispatcher and the `satd-alert` contract. Two
reviewers found the URL bug independently.

**A hook URL was validated by prefix match, never parsed — so an unresolvable
URL was accepted at load and then retried forever, silently.** `validate_url`
tested `starts_with("https://")`. `https://`, `https://:8080/h`,
`https://alerts.example:99999/h`, `https://[::1`, and a template placeholder
left as `https://<your-host>/hook` all pass that and all fail the WHATWG parser
`reqwest` actually resolves with. The daemon logged `alertfile loaded, hooks=1`
and started clean; then `reqwest` failed in the builder with no HTTP status,
`classify_response(None)` read that as *transient*, and the hook retried every
five minutes for the life of the process — never delivering, never dropping,
pinning the head of its serial queue until the 1024-slot queue overflowed and
took every real alert with it. Neither documented health signal fires in that
state: `dropped_total` stays 0 for months on a `status`-only hook, and
`last_success_age_seconds` reports 0 ("fresh") for a hook that has *never*
succeeded. `validate_url` now parses, and `deliver_loop` treats a builder error
as `Drop` rather than retrying something that cannot ever work.

**A malformed alertfile printed hook secrets to the log.** `toml_edit`'s error
`Display` quotes the offending source line verbatim, and the line most likely to
be mid-edit when TOML syntax breaks is `secret = "..."` — an operator rotating a
key drops the closing quote. Both callers log this with `error = %e`, so the
plaintext HMAC signing key landed in `debug.log` and anywhere that log ships.
The crate hand-writes `Debug` for `Hook` precisely to keep that key out of logs;
this defeated it from the other direction. The error now carries message and
line number only. Control-verified by
`a_toml_syntax_error_never_echoes_the_secret`.

**A webhook URL is frequently the credential itself** (Slack, Discord,
PagerDuty), and reqwest appends the full URL — userinfo included — to transport
and timeout errors. One refused connection wrote it to the log. Now logged
through `without_url()`.

**An event retried past the published freshness window is futile by
construction.** `signed_at` is fixed per event and `MAX_TIMESTAMP_SKEW_SECS` is
600, but the doubling curve reaches 600 s of cumulative age around attempt 10
and then retries at the 300 s cap indefinitely — against a conforming receiver
that is required to refuse every one of them. Worse, a gateway answering 503
rather than 4xx keeps it classified transient, so it never reaches `Drop` and
pins the queue permanently. The dispatcher now abandons an event once it ages
out.

**`reorg-legacy` was reserved in prose only.** An alertfile hook could take the
id, be handed the *same* `Arc<HookCounters>` as the built-in `reorgwebhook=`
dispatcher (metrics keys on the id string), and send `X-Satd-Hook: reorg-legacy`
under a different secret and a different contract version — so a receiver keying
its secret lookup off that header verifies with the wrong key. The parser now
refuses it, and a test in `satd` pins the two crates' constants together.

Also: `allow_insecure_http` was the one field that accepted-and-ignored a
wrong-typed value (`"true"` read as `false`, then rejecting the very hook the
operator opted in for); hook ids are capped at 64 chars since each becomes an
HTTP header value and a Prometheus label on every delivery; deleted
`replay_delivery_id`, dead since the durability machinery was removed, along
with three stale doc blocks that described a `GapState`, a `Lagged` notice and a
"resume position" that no longer exist. Fixed the manual's claim that IBD
suppresses everything but `status`/`heartbeat` — only `chain` is suppressed, so
a `mempool` hook keeps firing throughout a multi-day sync.

`unknown_keys_are_rejected_not_ignored` was also not testing what it named:
appending a key to the fixture puts it *inside* `[[webhook]]`, so both
assertions exercised the per-hook list and the top-level check had zero
coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH
The alertfile dispatcher applies `without_url()` to reqwest errors and says
why: a webhook URL is frequently the credential itself (Slack, Discord,
PagerDuty) and may carry userinfo, and reqwest's `Display` appends it
verbatim to transport and timeout errors. The legacy `reorgwebhook` path,
rewritten a few hundred lines below in the same change, did not.

So `reorgwebhook=https://hooks.slack.com/services/T00/B00/<secret>` plus one
endpoint blip during a reorg wrote the full credential to stdout, three
times per record, from where journald and any log shipper pick it up. The
behavior was inherited from the deleted `reorg_webhook_dispatcher`, but this
change rewrote the line and fixed the identical problem in its sibling.

Takes `result` by value in the failure arm — nothing below reads it — since
`without_url` consumes the error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
Three review findings on the dispatcher.

**`signed_at` was stamped at dequeue**, so the freshness window bounded
the retry span rather than the event's age. Delivery is serial per hook
and a hard-down endpoint burns its full retry budget on each event in
turn, so a `tip_stall` raised at T+0 could be popped at T+2h, signed
with `now`, sail through the receiver's 600s check, and page on-call for
a condition that raised and cleared two hours earlier. The signed
timestamp is the ONLY staleness signal the documented receiver algorithm
checks, so this defeated it entirely. The stamp is now taken once in the
fan-in, when the event leaves the bus — one clock read for every hook it
fans out to, so an event is never stale for one receiver and fresh for
another.

**A dead delivery task was re-adopted on every SIGHUP.** The keep-the-task
rule compared only the stanza, so a loop that returned early left a
closed channel that each reload happily kept — the config compares equal
forever, so nothing restarted it and the hook stayed dark for the life of
the process.

The liveness check alone would not have been reachable: `apply()`
short-circuits when the alertfile is unchanged, which is the common case
for a SIGHUP — an operator reloads precisely BECAUSE something is wrong.
So the short-circuit now makes an exception for a closed sender. Without
that, "edit the stanza to force a mismatch" remained the only recovery,
which is exactly what the finding described.

**Docs claimed things the code does not do.** The changelog and release
notes promised a `lagged` in-band gap notice; nothing synthesizes one and
the branch's own e2e asserts its absence, so an integrator treating
silence as proof of no gap would miss every drop. The manual said retries
continue "indefinitely" when they stop at the 600s freshness bound,
around attempt 10 — an operator reading that would think a 15-minute
relay redeploy was covered. And it still told `reorgwebhook=` users they
"need no edits" after redirect-following was removed, so a receiver
answering 301/302 now silently loses every reorg record while the
canonical doc says nothing changed.

The dead-task test was run against the previous behavior and fails there.
@bkeroack
bkeroack force-pushed the feature/a3-webhook-dispatcher branch from beceb29 to 0c4c1b7 Compare August 2, 2026 00:02
@bkeroack
bkeroack merged commit bca2229 into master Aug 2, 2026
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.

1 participant