feat(server): say at startup when alerting is unconfigured (#107)
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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user