feat(server): say at startup when alerting is unconfigured (#107)
CI / Lint / Test / Vet (pull_request) Successful in 36s
CI / Mirror to GitHub (pull_request) Skipped

buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and
every consumer skips a nil notifier silently. That is right for an
unconfigured homelab and wrong for an alert whose entire value is that
someone finds out: "configured and working" and "never configured" were
byte-identical in behaviour — no error, no warning, only a message that
never arrives, which nobody notices because nobody is waiting for an alert
they have never seen.

Two alerts were dark in production for exactly that reason (#100): the
governance-rewrite alert from #97, and the audit-degradation alert that
reports the audit trail is down. Both were found by firing a real event
and watching nothing happen, not by reading anything.

WARN rather than fatal, deliberately. The brain rule
`empty-credential-must-refuse-to-start-not-switch-modes` says an empty
credential must be a startup error rather than a silent mode switch — but
that is about a control being removed. Here observability degrades, no gate
is bypassed, and refusing to boot would break the unconfigured-homelab case
the nil notifier exists to support. What that rule demands and this
provides is that the mode switch stops being silent.

buildAuditSink already announces its own fallback ("capture audit: slog
sink (BRAIN_LOKI_URL unset)"), so only the notifier needed this.

alertingWarnings is pure and env-injected: the reason this gap survived is
that nothing about it was observable, so the fix is tested rather than
asserted.

Refs #107, #100, #97.
This commit is contained in:
2026-09-23 14:58:27 +02:00
parent e157311cd8
commit 67c3039448
2 changed files with 75 additions and 0 deletions
+40
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -36,3 +37,42 @@ func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
}
// --- #107: an unconfigured alert must announce itself ----------------------
func TestAlertingWarnings(t *testing.T) {
tests := []struct {
name string
env map[string]string
want []string // substrings that must appear across the warnings
none bool // true = expect no warnings at all
}{
{
// The failure this exists to prevent: two alerts dark in
// production, discovered only by firing a real event and watching
// nothing arrive (#100).
name: "notifier unconfigured names what will not fire",
env: map[string]string{},
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
},
{
name: "configured notifier is silent",
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
none: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := alertingWarnings(func(k string) string { return tt.env[k] })
if tt.none {
assert.Empty(t, got)
return
}
joined := strings.Join(got, " ")
for _, w := range tt.want {
assert.Contains(t, joined, w)
}
})
}
}