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) + } + }) + } +}