v0.223.0: the app-down alarm reached nobody (R-329), and the stop nobody heard (R-386)
gates / gates (push) Successful in 11s

R-329. NotifyAppStartFailures emitted severity "warn". The hub accepts exactly
{info, warning, error, critical} and silently coerces anything else to "info",
which severityNotifies then drops BEFORE both legs. Banner shown, event stored,
POST 200, no mail sent. One word.

This is the second time: DiskAlertKind.Severity emitted "warn" until v0.215.0
and its own comment records that every warning-level disk alert went to nobody.
A comment recorded the lesson and nothing enforced it. The guard is now an AST
walk over the whole controller - grep cannot work here, since "warn" appears
legitimately nine times as a healthcheck status vocabulary.

The sweep found exactly one bad severity. Its limits are stated: the walk cannot
follow a variable, so all six dynamic call sites are registered by name with the
values each can take, and a new one fails the test. Two of the six were found by
the guard, not by the hand sweep before it.

Also pinned: fillwatch.Band.Severity() returns "" for BandOK, which would vanish
the same way. It is unreachable because Check() notifies only on escalation -
but that safety lives in a different function from the one that looks unsafe, so
the test asserts the consequence rather than the mapping.

app_start_failed gains a customer toggle, DEFAULT OFF, per operator ruling. The
operator is mailed either way: processOperator never consults customer prefs.
It is deliberately NOT in operatorOnlyEvents, which would make the toggle a lie.

R-386. classifyRunStates decided "the customer stopped this" from the STATE, so
every stopped stack was assumed deliberate. Measured on demo-hp: privatebin
stopped out of band, nine scans, zero events, zero banner - while the comment
beside it claimed an out-of-band stop still alerts.

DesiredState already records the answer and has exactly one writer. Stopped ->
no alarm; Running -> alarm; absent -> UNKNOWN, keep today's behaviour AND say
so. Absent stays silent deliberately: reading it as "nobody asked" would email
about every app anyone ever stopped, fleet-wide, on the first cycle after
upgrade. The gap is bounded not silent - IntentUnknown is set and the names are
logged at INFO on the heartbeat cadence. failedRestart still lifts a Stopped
intent, or F-CRIT-1 re-opens. No new DesiredState writer.

Two settings toggles each governed two alarms. "Lemez figyelmeztetes (90%+)"
also wrote disk_critical, the drive-is-FAILING alarm. Now four honest toggles;
12 became 15. A no-op save stores the existing slice verbatim, so byte identity
is by construction - without that guard the defaults case reorders, which the
red-proof caught.

Test count 1504 -> 1522. Five red-proofs, five seen failing; one passed first
time and is reported - that mutation was inert, not the test weak.
This commit is contained in:
2026-08-23 11:21:06 +02:00
parent 14137efac5
commit 9832760027
9 changed files with 1086 additions and 12 deletions
+82 -2
View File
@@ -732,6 +732,7 @@ func main() {
notifier.NotifyAppStartFailures(states)
deadAppScans++
noteDeadAppScan(logger, deadAppScans, len(states), len(dead))
noteUnknownIntentSuppressions(logger, states)
return nil
})
@@ -1731,6 +1732,36 @@ func noteDeadAppScan(logger *log.Logger, scans, evaluated, down int) {
scans, evaluated, down)
}
// noteUnknownIntentSuppressions reports every app whose dead-app alarm was suppressed ONLY because
// no customer intent was ever recorded (R-386, §4's ruling).
//
// **A rule without a mechanism is not a rule.** §4 chose to keep today's behaviour for the unknown
// case, which is the safe choice — but a safe choice that is invisible is indistinguishable from the
// defect it replaced. This line is what makes the gap BOUNDED rather than silent: an operator can
// grep one word and answer "how many apps am I blind to, and which?".
//
// It rides the same cadence as the F-OBS heartbeat rather than firing every 30 s, for the reason
// noteDeadAppScan records: a per-scan line is 2880 lines/day of noise, and noise is what made the
// original author choose silence. The population only changes when someone starts or stops an app.
func noteUnknownIntentSuppressions(logger *log.Logger, states []notify.AppRunState) {
if logger == nil || deadAppHeartbeatEvery <= 0 || deadAppScans%deadAppHeartbeatEvery != 0 {
return
}
var names []string
for _, s := range states {
if s.IntentUnknown {
names = append(names, s.Name)
}
}
if len(names) == 0 {
return
}
sort.Strings(names)
logger.Printf("[INFO] [deadapp] %d stopped app(s) have NO recorded customer intent, so their "+
"dead-app alarm is suppressed by the unknown-intent fallback (R-386): %s. This closes itself "+
"as each app is started or stopped through the interface.", len(names), strings.Join(names, ", "))
}
// bootReconcileSettle is the delay before the FIRST observation. It lets the initial scan, the first
// status refresh and the two crash recoveries land before the R-52 sweep decides what "down" means.
var bootReconcileSettle = 5 * time.Second
@@ -2169,9 +2200,58 @@ func classifyRunStates(sts []stacks.Stack, quiesced map[string]bool, failedResta
// brief restart never reaches it and the two suppression windows compose into one bounded
// delay. Quiesce suppression below still wins inside its own window.
crashLooping := st.CrashLooping(now)
userStopped := st.State == stacks.StateStopped && !failedRestart[st.Name]
// R-386: ASK THE FIELD THAT KNOWS, do not guess from the state.
//
// Until v0.223.0 this read `st.State == StateStopped && !failedRestart[...]` — i.e. EVERY
// stopped stack was assumed to be a deliberate customer stop. Measured on `demo-hp`
// 2026-08-23: `privatebin` stopped out of band, nine dead-app scans, zero events, zero
// banner. The comment above claimed an out-of-band stop "still alerts"; it did not, because
// `aggregateState` folds StateExited into the stopped counter and StateExited never survives
// aggregation.
//
// `DesiredState` is what the customer actually asked for, it has exactly ONE writer (the
// customer's own action — see the field's comment in internal/stacks/deploy.go), and it is
// TRI-state. The three-way ruling, operator-approved 2026-08-23:
//
// Stopped → the customer asked → no alarm (unchanged)
// Running → nobody asked → ALARM (the R-386 fix)
// absent → UNKNOWN, never "running" → no alarm, AND say so (see IntentUnknown)
//
// The absent case keeps today's behaviour deliberately. Reading unknown as "nobody asked"
// would, on the first cycle after this ships, email about every app any owner has ever
// deliberately stopped — fleet-wide, from a field that predates the intent it is being asked
// about. That is the same over-correction the tri-state exists to prevent, and the backfill
// cannot help: it seeds Running only from an observed-UP reading, so anything stopped at
// upgrade time stays unknown — which is exactly the ambiguous population.
//
// The gap is BOUNDED AND NAMED, not silent: IntentUnknown is set, the caller logs it at INFO
// with the app name, and an operator can therefore answer "how many apps am I blind to?".
// It closes itself as apps are started and stopped through the interface.
//
// `failedRestart` still lifts a Stopped intent, and that ordering is load-bearing: the
// quiesce loop stops stacks by the same path a customer does, so a stack it stopped and could
// NOT restart must alarm whatever the intent says. Removing that term re-opens F-CRIT-1's
// indefinitely-silent dead app.
intentUnknown := false
userStopped := false
if st.State == stacks.StateStopped && !failedRestart[st.Name] {
switch stacks.DesiredStateOf(st) {
case stacks.DesiredStateStopped:
userStopped = true
case stacks.DesiredStateRunning:
userStopped = false
default: // DesiredStateUnknown — the §4 fallback
userStopped = true
intentUnknown = true
}
}
down := (stacks.IsDownState(st.State) || crashLooping) && !userStopped && !quiesced[st.Name]
states = append(states, notify.AppRunState{Name: st.Name, DisplayName: st.Meta.DisplayName, Down: down})
states = append(states, notify.AppRunState{
Name: st.Name, DisplayName: st.Meta.DisplayName, Down: down,
IntentUnknown: intentUnknown && !down,
})
if down {
dead = append(dead, web.DeadApp{Name: st.Name, DisplayName: st.Meta.DisplayName, State: string(st.State)})
}