hub v0.122.0: app_hold_no_whole_copy — allow-listed, operator-only, per-app cooldown (R-659)
gates / gates (push) Successful in 28s

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 07:33:04 +02:00
parent 3e58c184f6
commit e4d45a8f72
5 changed files with 42 additions and 2 deletions
+14
View File
@@ -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) ## 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 Controller v0.265.0 sends `app_oom_storm` (severity `error`) once per container run when the kernel's
@@ -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) 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)
}
}
+4
View File
@@ -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. // 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. // Operator-only (notify.operatorOnlyEvents) — registered in BOTH in the same commit.
"app_oom_storm": true, "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 // Special
"test": true, "test": true,
} }
+9
View File
@@ -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 // 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.) // night are two alarms. (The controller already sends it at most once per container run.)
"app_oom_storm": true, "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 // 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 // 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. // figures; the household's side is the dashboard. Registered in the same commit that mints it.
"app_oom_storm": true, "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 // IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the
@@ -69,7 +69,8 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
// two alarms. The backup family stays coarse, as the fence says. // two alarms. The backup family stays coarse, as the fence says.
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.
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) ok := len(perAppCooldownEvents) == len(want)
for _, w := range want { for _, w := range want {
ok = ok && perAppCooldownEvents[w] ok = ok && perAppCooldownEvents[w]
@@ -79,7 +80,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_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 "+ "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)
} }