alerting: satd-alert crate + webhook dispatcher, absorbing reorgwebhook - #482
Conversation
eb6b6d2 to
a2d97b0
Compare
|
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, 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. |
59a89f4 to
8486e10
Compare
aa6573d to
6addf3e
Compare
04cb6da to
3f67877
Compare
050aaf8 to
1f0db76
Compare
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 ( 1. The legacy reorg path logs the webhook URL verbatim — MEDIUM [verified]
2.
|
2948bcd to
001966e
Compare
001966e to
1cd27d0
Compare
5a978b9 to
7eb8351
Compare
1cd27d0 to
beceb29
Compare
Tier 1 + Tier 2 fixes (
|
7eb8351 to
aab93b6
Compare
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
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.
beceb29 to
0c4c1b7
Compare
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:New
satd-alertcrate 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.reqwestand the dispatcher task stay in the binary. Same split, same reasoning, assatd-auth.Decisions worth reviewing
laggedbody carrying the count and a resume cursor ahead of the next delivery.reorgwebhook=is absorbed, not replaced. Its payload stays the shippedReorgRecordJSON byte for byte —ChainEvent::Reorgcarries neitherdepth,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).http://is gated to loopback/RFC1918 unlessallow_insecure_http = true. Bodies carry chain data rather than secrets, but signed-then-cleartext is still a footgun.satd-alertdepends onnode(unlikesatd-auth, which depends on nothing). Deliberate: the health taxonomy gets exactly one definition, so an alertfile'skinds/categories/min_severityare 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)
X-Satd-Attempt, permanent 4xx not wedging the queue behind it.satd-alert(reproduced in the docs; the relay will re-assert them).HANDLER_RELOAD_KEYScoverage test.satd_alertwebhook_*on/metrics(unit-asserted, same invisibility property the policy metrics have).--workspace --all-targets --all-featuresclean; full suite green except the 10 pre-existingtest_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.mdspec + the stalled-endpoint latency bench are PR 5, per the design's split.🤖 Generated with Claude Code
https://claude.ai/code/session_018AfAm3VVN8gBeYcTL5BJqH