From 67c30394489f8128d87a4e5426e3bac7f52e6e87 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 22 Aug 2026 16:10:38 +0200 Subject: [PATCH] feat(server): say at startup when alerting is unconfigured (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ingestion/cmd/server/main.go | 35 +++++++++++++++++++++++++++ ingestion/cmd/server/main_test.go | 40 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index e512ab2..bc38f11 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -147,6 +147,37 @@ func envInt(key string, fallback int) int { // buildNotifier returns the ntfy notifier, or a nil interface when // BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so // DegradingSink/Reconcile and the governance observer skip it cleanly. +// alertingWarnings names every alert that will not fire under the current +// configuration (#107). +// +// buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and every +// consumer skips a nil notifier silently — right for an unconfigured homelab, +// wrong for an alert whose entire value is that someone finds out. The cost of +// the silence is that "configured and working" and "never configured" are +// 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), found by firing a real event rather than by reading anything. +// +// WARN rather than fatal, deliberately. The brain sibling rule +// (`empty-credential-must-refuse-to-start-not-switch-modes`) says an empty +// credential must be a startup error, never a silent mode switch — but that is +// about a control being removed. Here observability is degraded, not a gate +// bypassed, and refusing to boot would break the unconfigured-homelab case the +// nil notifier exists to support. What the rule does demand, and what this +// provides, is that the mode switch stops being silent. +// +// Pure and env-injected so the message is testable without a running server: +// the reason this gap survived is that nothing about it was observable. +func alertingWarnings(getenv func(string) string) []string { + var out []string + if getenv("BRAIN_NTFY_URL") == "" { + out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+ + "brain_edit_root governance rewrites and capture audit degradation will not notify") + } + return out +} + func buildNotifier() audit.Notifier { if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil { return n @@ -203,6 +234,10 @@ func systemHostname() string { func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + for _, w := range alertingWarnings(os.Getenv) { + logger.Warn(w) + } + brainDir := envOr("INGEST_BRAIN_DIR", "../brain") port := envOr("INGEST_PORT", "3300") diff --git a/ingestion/cmd/server/main_test.go b/ingestion/cmd/server/main_test.go index 41965b0..4d1236d 100644 --- a/ingestion/cmd/server/main_test.go +++ b/ingestion/cmd/server/main_test.go @@ -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) + } + }) + } +} -- 2.54.0