From e4d45a8f724e4d6d667d03bf2fff5a8f18038b83 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 24 Sep 2026 07:33:04 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.122.0:=20app=5Fhold=5Fno=5Fwhole=5Fcop?= =?UTF-8?q?y=20=E2=80=94=20allow-listed,=20operator-only,=20per-app=20cool?= =?UTF-8?q?down=20(R-659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/CHANGELOG.md | 14 ++++++++++++++ hub/internal/api/chaosnight_events_test.go | 12 ++++++++++++ hub/internal/api/handler.go | 4 ++++ hub/internal/notify/dispatcher.go | 9 +++++++++ hub/internal/notify/r389_cooldown_grain_test.go | 5 +++-- 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index f42f45a1..4641ffa1 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,17 @@ +## 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 +automatic undo failed and the box holds no copy that brings the app back together with its files +(operator ruling 2026-09-24, `09` §3 decision 25, option A). Details: `app`, `stack_name`, `from`, `to`, +`at`, `copies_seen`, `undo_state`. The household still gets its `app_update_held` mail, whose sentence +now says that no such copy exists and that support is informed. + +- **Allow-listed AND operator-only, in the same commit** (`allowedEventTypes` + `operatorOnlyEvents`). + Pinned together by `TestAppHoldNoWholeCopyIsAllowlistedAndOperatorOnly` (red-proof: dropped from + `operatorOnlyEvents` → the test fails at "must be operator-only"). +- **Per-app operator cooldown:** joined R-389's `perAppCooldownEvents` (the fence test widened on + purpose, with its reason) — two apps stranded on one night are two alarms. + ## v0.121.0 — `app_oom_storm`: a repeating OOM problem reaches the operator louder (2026-09-23, R-636) Controller v0.265.0 sends `app_oom_storm` (severity `error`) once per container run when the kernel's diff --git a/hub/internal/api/chaosnight_events_test.go b/hub/internal/api/chaosnight_events_test.go index 03c91cf6..46166327 100644 --- a/hub/internal/api/chaosnight_events_test.go +++ b/hub/internal/api/chaosnight_events_test.go @@ -80,3 +80,15 @@ func TestAppOOMStormIsAllowlistedAndOperatorOnly(t *testing.T) { t.Fatalf("%s must be operator-only — container names and memory figures are not a household's business", et) } } + +// R-659 (v0.122.0) — app_hold_no_whole_copy is allow-listed AND operator-only, pinned together. +// RED-PROOF (REPORT.md): drop it from operatorOnlyEvents → "must be operator-only". +func TestAppHoldNoWholeCopyIsAllowlistedAndOperatorOnly(t *testing.T) { + et := "app_hold_no_whole_copy" + 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 be operator-only — the household is told by app_update_held; this carries support's detail", et) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index e2e97de4..4f68839a 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2143,6 +2143,10 @@ var allowedEventTypes = map[string]bool{ // R-636 (hub v0.121.0, controller v0.265.0): the same container run OOM-killed 20+ times in 30 min. // Operator-only (notify.operatorOnlyEvents) — registered in BOTH in the same commit. "app_oom_storm": true, + // R-659 (hub v0.122.0, controller v0.268.0): an app held after a failed update and a failed undo + // with NO copy on its box that brings it back whole — support must act. Operator-only + // (notify.operatorOnlyEvents) — registered in BOTH in the same commit. + "app_hold_no_whole_copy": true, // Special "test": true, } diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 1fca20e7..46d1c7ad 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -395,6 +395,9 @@ var perAppCooldownEvents = map[string]bool{ // v0.121.0 (R-636): a storm is one app's event with no digest behind it — two apps storming on one // night are two alarms. (The controller already sends it at most once per container run.) "app_oom_storm": true, + // 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. + "app_hold_no_whole_copy": true, } // perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The @@ -659,6 +662,12 @@ var operatorOnlyEvents = map[string]bool{ // OOM-killed 20+ times in 30 minutes. Same audience, same reason — raw container names and memory // figures; the household's side is the dashboard. Registered in the same commit that mints it. "app_oom_storm": true, + // R-659 (v0.122.0, controller v0.268.0; operator ruling 2026-09-24, `09` §3 decision 25). A held app + // whose box holds no copy that brings it back WHOLE. The household is told by its own + // `app_update_held` mail, in its language, that support is informed; THIS is that information — + // operator-grade (the copies seen, per tier, with dates), and the act it calls for is support's. + // Registered in the same commit that mints it. + "app_hold_no_whole_copy": true, } // IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the diff --git a/hub/internal/notify/r389_cooldown_grain_test.go b/hub/internal/notify/r389_cooldown_grain_test.go index ba55eb0d..27ff26f5 100644 --- a/hub/internal/notify/r389_cooldown_grain_test.go +++ b/hub/internal/notify/r389_cooldown_grain_test.go @@ -69,7 +69,8 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) { // two alarms. The backup family stays coarse, as the fence says. 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. - want := []string{"app_oom_storm", "app_start_failed", "app_update_held", "app_update_undone"} + // 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"} ok := len(perAppCooldownEvents) == len(want) for _, w := range want { ok = ok && perAppCooldownEvents[w] @@ -79,7 +80,7 @@ func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) { for k := range perAppCooldownEvents { got = append(got, k) } - t.Fatalf("perAppCooldownEvents = %v, want exactly [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_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 "+ "disk sends one digest, not one mail per app. Read the fence before widening this.", got) }