controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s

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 08:15:27 +02:00
parent 80e6ad8c47
commit 206b0357d1
36 changed files with 2201 additions and 40 deletions
+25
View File
@@ -1304,6 +1304,31 @@ func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang st
n.pushEventBoth("app_update_held", "error", hu, household, d)
}
// AppHoldNoWholeCopyDetails is the payload of app_hold_no_whole_copy (v0.268.0, R-659).
type AppHoldNoWholeCopyDetails struct {
App string `json:"app"`
StackName string `json:"stack_name"`
From map[string]string `json:"from,omitempty"`
To map[string]string `json:"to,omitempty"`
At string `json:"at"`
CopiesSeen []string `json:"copies_seen"`
UndoState string `json:"undo_state,omitempty"`
}
// NotifyAppHoldNoWholeCopy (v0.268.0, R-659; operator ruling 2026-09-24, option A): an app is held
// after a failed update AND a failed undo, and no copy on its box brings it back whole. OPERATOR-ONLY
// on the hub (operatorOnlyEvents) — the household's side is its app_update_held mail, which now says
// support is informed; this event is that information. Severity critical: a household's app is
// stopped and only support can bring it back.
func (n *Notifier) NotifyAppHoldNoWholeCopy(d AppHoldNoWholeCopyDetails) {
if d.CopiesSeen == nil {
d.CopiesSeen = []string{} // the JSON reads [] — "none seen" is a finding, not a missing field
}
msg := fmt.Sprintf("%s is HELD after a failed update and a failed undo (%s), and NO copy on this box brings it back whole. Copies seen: %v. Support must act.",
d.App, d.UndoState, d.CopiesSeen)
n.pushEventBoth("app_hold_no_whole_copy", "critical", msg, "", d)
}
// healthSeverity is the event severity a health status would be sent at (R-647): the disabled path
// names what it drops, and "warn" is a health STATUS, not a severity the hub knows.
func healthSeverity(status string) string {
@@ -0,0 +1,34 @@
package notify
import (
"encoding/json"
"strings"
"testing"
)
// R-659 (v0.268.0) — app_hold_no_whole_copy: severity critical (the hub's vocabulary), the operator
// message only (no household sentence — the hub bars the type from customers too), and the details
// carry what support needs: app, from, to, at, copies_seen (`[]`, never null, when none were seen).
func TestR659_NoWholeCopyEvent(t *testing.T) {
n := &Notifier{}
var et, sev, msg, cust string
var det []byte
n.pushFn = func(eventType, severity, message, customer string, details interface{}) {
et, sev, msg, cust = eventType, severity, message, customer
det, _ = json.Marshal(details)
}
n.NotifyAppHoldNoWholeCopy(AppHoldNoWholeCopyDetails{App: "nextcloud", StackName: "nextcloud",
From: map[string]string{"nextcloud": "nextcloud:34.0.1"}, To: map[string]string{"nextcloud": "nextcloud:34.0.4"},
At: "2026-09-23T21:42:39Z", UndoState: "untouched"})
if et != "app_hold_no_whole_copy" || sev != "critical" || cust != "" {
t.Fatalf("type=%q severity=%q customer=%q", et, sev, cust)
}
if !strings.Contains(msg, "nextcloud") || !strings.Contains(msg, "Support must act") {
t.Fatalf("message = %q", msg)
}
for _, want := range []string{`"app":"nextcloud"`, `"stack_name":"nextcloud"`, `"copies_seen":[]`, `"at":"2026-09-23T21:42:39Z"`, `"from":{`, `"to":{`} {
if !strings.Contains(string(det), want) {
t.Fatalf("details %s lack %s", det, want)
}
}
}