fix(ingestion): tripwire alerts survive outages; drop only permanent rejections
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped

PR #160 round 2: dropping a message after 5 failures could not tell a
poison message from an ntfy outage. A 15-minute outage dropped the only
incident with nothing reported, and drops were not counted (74 of 300
removals were neither delivered nor counted).

- audit.NtfyNotifier returns *RejectedError for non-2xx; Permanent() is
  true for 4xx other than 429.
- The tripwire queue drops a message only on a permanent rejection and
  counts it; every other failure is an outage and is retried, however
  long. Each flush tries every queued message, so an undeliverable one
  cannot block the rest.
- Overflow and drops go into one counter, sent as a summary retried
  every tick and never dropped. Every removal is delivered or counted.

Tests (red first): an outage of ~100 ticks still delivers the only
incident; delivered + counted equals the number of removals; a
permanently rejected message is dropped and counted. The short-outage
test is renamed to say it is short. The Watch doc no longer claims
'loses none' unconditionally.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:30:46 +02:00
co-authored by Claude Opus 5.5
parent 01de163452
commit 4ba9d20b4e
4 changed files with 173 additions and 31 deletions
+38 -28
View File
@@ -9,6 +9,7 @@ package symlinkguard
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
@@ -28,7 +29,6 @@ const DefaultInterval = 30 * time.Second
const (
maxPathsPerAlert = 10 // further paths are counted, not listed
maxPending = 5 // queued alerts; the rest become one summary
maxAttempts = 5 // a message failing this often is dropped
)
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
@@ -130,8 +130,9 @@ func Scan(brainDir string) (Result, error) {
// hyperguild#157 decision), so one is readable for at most one interval.
// Each removal is an alert; a persistent finding (a link it could not remove,
// an unreadable dir, a scan error) alerts once per distinct state. Alerts
// queue until Notify succeeds, so an outage delays them but loses none, even
// though the next scan of a cleaned brain is clean.
// queue until Notify succeeds (see alertQueue): an outage delays them, and
// any that do not fit the bounded queue, or are undeliverable, are counted in
// a summary that is never dropped, so every incident is delivered or counted.
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
if interval <= 0 {
interval = DefaultInterval
@@ -171,25 +172,28 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
}
}
// alertQueue holds alerts until Notify accepts them, bounded: at most
// maxPending queued, further ones counted into one summary, and a message
// that fails maxAttempts times dropped so it cannot block the rest.
// alertQueue holds alerts until Notify accepts them. It is bounded (at most
// maxPending queued) and loses nothing from the record: every overflow and
// every dropped message increments suppressed, sent as a summary that is
// retried every tick and never dropped.
//
// A message is dropped only when the notifier rejects it permanently (an
// error with Permanent() == true, e.g. ntfy 413). Every other failure is an
// outage, and nothing is dropped however long it lasts (PR #160 round 2:
// dropping after N raw failures lost the only incident of a long outage).
// Each flush tries every queued message, so one undeliverable message cannot
// block the others.
type alertQueue struct {
items []queued
items []string
suppressed int
}
type queued struct {
msg string
attempts int
}
func (q *alertQueue) push(msg string) {
if len(q.items) >= maxPending {
q.suppressed++
return
}
q.items = append(q.items, queued{msg: msg})
q.items = append(q.items, msg)
}
func (q *alertQueue) flush(ctx context.Context, n Notifier) {
@@ -197,28 +201,34 @@ func (q *alertQueue) flush(ctx context.Context, n Notifier) {
q.items, q.suppressed = nil, 0
return
}
for len(q.items) > 0 {
head := &q.items[0]
if err := n.Notify(ctx, head.msg); err != nil {
head.attempts++
if head.attempts >= maxAttempts {
slog.Error("symlink tripwire alert dropped after repeated failures", "attempts", head.attempts, "err", err)
q.items = q.items[1:]
continue
}
kept := q.items[:0]
for _, msg := range q.items {
err := n.Notify(ctx, msg)
switch {
case err == nil:
case permanent(err):
slog.Error("symlink tripwire alert rejected permanently; counted in the summary", "err", err)
q.suppressed++
default:
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
return
kept = append(kept, msg)
}
q.items = q.items[1:]
}
q.items = kept
if q.suppressed > 0 {
q.items = append(q.items, queued{msg: fmt.Sprintf(
"brain symlink tripwire (hyperguild#157): %d further alert(s) were suppressed while the notifier was failing; check the ingestion logs", q.suppressed)})
q.suppressed = 0
q.flush(ctx, n)
msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): %d further alert(s) were suppressed or undeliverable; check the ingestion logs", q.suppressed)
if err := n.Notify(ctx, msg); err == nil {
q.suppressed = 0
}
}
}
// permanent reports whether err says the message itself can never be sent.
func permanent(err error) bool {
var p interface{ Permanent() bool }
return errors.As(err, &p) && p.Permanent()
}
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
// the brain dir, after re-checking it is still a symlink. Removing a symlink
// never touches its target. It returns what was removed and what was not.