hub v0.123.0: app_stopped_unhealthy (decision 28); 09 decisions 26-28 recorded
gates / gates (push) Successful in 31s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 11:38:13 +02:00
parent 86c4b9a0d8
commit 99c0709cbe
13 changed files with 109 additions and 4 deletions
@@ -329,6 +329,18 @@ builds them (`audits/update-rulings-2026-09-23/`).
operator-only). The operator's English was used with one word dropped („please" — the house rule, operator-only). The operator's English was used with one word dropped („please" — the house rule,
`i18n_missing_gate.py`); the Hungarian verbatim, its formal register recorded against R-516. `i18n_missing_gate.py`); the Hungarian verbatim, its formal register recorded against R-516.
### 2026-09-24 (afternoon) — three more operator rulings
26. **R-661, option A: one action brings a file app back WHOLE from the second drive** — the unit (settings +
database) and the drive files together. The second drive then counts as a whole copy in decision 25's
truth table. R-538's guard stays: this is a new action beside it, not its removal.
27. **R-666, option B: while a held app's page says support is informed, Remove offers only „remove the app,
keep my data".** The household keeps control; the data stays. The no-whole-copy sentence moves to the
informal voice, like every other screen.
28. **An app in a crash loop, or in an out-of-memory storm, is STOPPED by the box, and the household and the
operator are told. A press on Start gives it one more try.** Operator's words: *"if it is in a crashloop,
or consuming resources, then yes, definitely stopped at least."*
**RomM follow-ups, operator-agreed the same day:** the test bench watches memory after an update **RomM follow-ups, operator-agreed the same day:** the test bench watches memory after an update
(`upgrade-test.py`, 2026-09-23); a version move checks the memory limit (gate or checklist — §6.4); (`upgrade-test.py`, 2026-09-23); a version move checks the memory limit (gate or checklist — §6.4);
R-636's louder repeated alarm. R-636's louder repeated alarm.
+15
View File
@@ -1,3 +1,18 @@
## v0.123.0 — `app_stopped_unhealthy`: the household is told when the box stops a crashing app (2026-09-24, `09` §3 decision 28)
Controller v0.269.0 stops an app in a crash loop (≥ 6 restarts in 10 min) or an out-of-memory storm (≥ 20
kernel OOM kills in 30 min) and sends `app_stopped_unhealthy` (warning), details `app`, `stack_name`, `kind`,
`count`, `window_min`, `trip` (2 = a repeat within 24 h, the household sentence then says support is told).
- **A household event:** `allowedEventTypes`; `mail.event.app_stopped_unhealthy` in hu AND en (app named);
`appNamedMailEvents`; `defaultSeedEvents`; the one-time ADD-ONLY `seed_app_stopped_unhealthy_v1` for existing
households (the v0.120.0 shape — the box pushes its list on every notifications-page save, and seeds the same
type add-only on its side).
- **Per-app cooldown on both legs** (`perAppCooldownEvents`, `perAppCustomerCooldownEvents`; the R-389 fence test
widened on purpose).
- Pinned by `TestAppStoppedUnhealthyIsAHouseholdEvent` (red-proof: dropped from `allowedEventTypes` → fails).
Mail goldens captured for the new type (hu, en).
## v0.122.0 — `app_hold_no_whole_copy`: a stranded held app reaches support (2026-09-24, R-659) ## v0.122.0 — `app_hold_no_whole_copy`: a stranded held app reaches support (2026-09-24, R-659)
Controller v0.268.0 sends `app_hold_no_whole_copy` (severity `critical`) when an update AND its Controller v0.268.0 sends `app_hold_no_whole_copy` (severity `critical`) when an update AND its
@@ -92,3 +92,22 @@ func TestAppHoldNoWholeCopyIsAllowlistedAndOperatorOnly(t *testing.T) {
t.Fatalf("%s must be operator-only — the household is told by app_update_held; this carries support's detail", et) t.Fatalf("%s must be operator-only — the household is told by app_update_held; this carries support's detail", et)
} }
} }
// Decision 28 (hub v0.123.0) — app_stopped_unhealthy is a HOUSEHOLD event: allow-listed, NOT operator-only,
// and it has a Hungarian AND English mail entry (never the raw-English fallback).
// RED-PROOF (REPORT.md): drop it from allowedEventTypes → "the controller's push would 400".
func TestAppStoppedUnhealthyIsAHouseholdEvent(t *testing.T) {
et := "app_stopped_unhealthy"
if !allowedEventTypes[et] {
t.Fatalf("%s must be in allowedEventTypes — the controller's push would 400", et)
}
if notify.IsOperatorOnly(et) {
t.Fatalf("%s must reach the household", et)
}
for _, lang := range []string{"hu", "en"} {
subject, _ := notify.FormatCustomerEmail(lang, "c1", et, "warning", "RAW-ENGLISH-SENTINEL", "", `{"stack_name":"gokapi"}`)
if strings.Contains(subject, "RAW-ENGLISH-SENTINEL") || !strings.Contains(subject, "gokapi:") {
t.Fatalf("%s/%s subject must be the mail entry naming the app, got %q", et, lang, subject)
}
}
}
+3
View File
@@ -2019,6 +2019,9 @@ var allowedEventTypes = map[string]bool{
// operator; both carry a Hungarian AND English `mail.event.*` entry, never the raw-English fallback. // operator; both carry a Hungarian AND English `mail.event.*` entry, never the raw-English fallback.
"app_update_undone": true, "app_update_undone": true,
"app_update_held": true, "app_update_held": true,
// v0.123.0 (decision 28, controller v0.269.0): the box stopped an app that kept crashing / ran out
// of memory. Household + operator; hu AND en `mail.event.*`; per-app cooldown on both legs.
"app_stopped_unhealthy": true,
// Controller-pushed events // Controller-pushed events
"controller_started": true, "controller_started": true,
+3
View File
@@ -238,6 +238,9 @@ var defaultSeedEvents = []string{
// add-only store.SeedEventTypesOnce. // add-only store.SeedEventTypesOnce.
"app_update_undone", "app_update_undone",
"app_update_held", "app_update_held",
// v0.123.0 (`09` §3 decision 28): the box stopped an app in a crash loop / OOM storm. Existing
// households get it by the one-time, add-only store.SeedEventTypesOnce.
"app_stopped_unhealthy",
} }
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation // MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
+2 -1
View File
@@ -81,5 +81,6 @@
"mail.test.subject": "[Felhom] Test notification", "mail.test.subject": "[Felhom] Test notification",
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring", "mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version", "mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version",
"mail.event.app_update_held": "%s: the app is stopped and needs a restore" "mail.event.app_update_held": "%s: the app is stopped and needs a restore",
"mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing"
} }
+2 -1
View File
@@ -81,5 +81,6 @@
"mail.test.subject": "[Felhom] Teszt értesítés", "mail.test.subject": "[Felhom] Teszt értesítés",
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring", "mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut", "mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut",
"mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges" "mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges",
"mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott"
} }
+4
View File
@@ -398,6 +398,8 @@ var perAppCooldownEvents = map[string]bool{
// v0.122.0 (R-659): a held app with no whole copy on its box is one app's event with no digest — // v0.122.0 (R-659): a held app with no whole copy on its box is one app's event with no digest —
// two apps stranded on one night are two alarms, and the second must not be swallowed. // two apps stranded on one night are two alarms, and the second must not be swallowed.
"app_hold_no_whole_copy": true, "app_hold_no_whole_copy": true,
// v0.123.0 (decision 28): one app's stop, no digest behind it.
"app_stopped_unhealthy": true,
} }
// perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The // perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The
@@ -407,6 +409,8 @@ var perAppCooldownEvents = map[string]bool{
var perAppCustomerCooldownEvents = map[string]bool{ var perAppCustomerCooldownEvents = map[string]bool{
"app_update_undone": true, "app_update_undone": true,
"app_update_held": true, "app_update_held": true,
// v0.123.0 (decision 28): two apps stopped on one night are two mails.
"app_stopped_unhealthy": true,
} }
// cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name` // cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name`
@@ -70,7 +70,8 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) { func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
// v0.121.0 widened it again, on purpose (R-636): an OOM storm is one app's event with no digest. // v0.121.0 widened it again, on purpose (R-636): an OOM storm is one app's event with no digest.
// v0.122.0 once more (R-659): a stranded held app is one app's event with no digest. // v0.122.0 once more (R-659): a stranded held app is one app's event with no digest.
want := []string{"app_hold_no_whole_copy", "app_oom_storm", "app_start_failed", "app_update_held", "app_update_undone"} // v0.123.0 once more (decision 28): an unhealthy stop is one app's event with no digest.
want := []string{"app_hold_no_whole_copy", "app_oom_storm", "app_start_failed", "app_stopped_unhealthy", "app_update_held", "app_update_undone"}
ok := len(perAppCooldownEvents) == len(want) ok := len(perAppCooldownEvents) == len(want)
for _, w := range want { for _, w := range want {
ok = ok && perAppCooldownEvents[w] ok = ok && perAppCooldownEvents[w]
@@ -80,7 +81,7 @@ func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
for k := range perAppCooldownEvents { for k := range perAppCooldownEvents {
got = append(got, k) got = append(got, k)
} }
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_hold_no_whole_copy app_oom_storm app_start_failed app_update_held app_update_undone]. Adding a member is the "+ t.Fatalf("perAppCooldownEvents = %v, want exactly [app_hold_no_whole_copy app_oom_storm app_start_failed app_stopped_unhealthy app_update_held app_update_undone]. Adding a member is the "+
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+ "fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
"disk sends one digest, not one mail per app. Read the fence before widening this.", got) "disk sends one digest, not one mail per app. Read the fence before widening this.", got)
} }
+2
View File
@@ -383,6 +383,8 @@ func trimRepeatedUsage(reason, targetPath string) string {
var appNamedMailEvents = map[string]bool{ var appNamedMailEvents = map[string]bool{
"app_update_undone": true, "app_update_undone": true,
"app_update_held": true, "app_update_held": true,
// v0.123.0 (`09` §3 decision 28): the box stopped an app that kept crashing / ran out of memory.
"app_stopped_unhealthy": true,
} }
// stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable. // stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable.
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the app was stopped because it kept crashing
---
Dear Customer,
Your Felhom system sent the following notification:
?: the app was stopped because it kept crashing
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_stopped_unhealthy
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: az alkalmazást leállítottuk, mert újra és újra összeomlott
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: az alkalmazást leállítottuk, mert újra és újra összeomlott
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_stopped_unhealthy
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
+8
View File
@@ -801,6 +801,14 @@ func (s *Store) migrate() error {
s.logger.Printf("[INFO] [store] app-update event types added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed) s.logger.Printf("[INFO] [store] app-update event types added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed)
} }
// v0.123.0 (`09` §3 decision 28): every existing household hears about an app the box stopped.
// ONE-TIME and ADD-ONLY — see SeedEventTypesOnce.
if changed, err := s.SeedEventTypesOnce("seed_app_stopped_unhealthy_v1", []string{"app_stopped_unhealthy"}); err != nil {
return fmt.Errorf("unhealthy-stop event seed: %w", err)
} else if changed != nil && s.logger != nil {
s.logger.Printf("[INFO] [store] app_stopped_unhealthy added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed)
}
return nil return nil
} }