controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s

app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

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-23 13:51:21 +02:00
parent c3a2aba0d2
commit bc278944a3
27 changed files with 1086 additions and 86 deletions
@@ -0,0 +1,48 @@
package settings
import (
"io"
"log"
"os"
"path/filepath"
"testing"
)
// v0.264.0 — the one-time, ADD-ONLY seed of the two update event types into a household's stored prefs.
//
// COMPANION RED-PROOF (REPORT.md): drop the seedAppUpdateEvents call from Load — the first assertion fails.
func TestSeedAppUpdateEvents_AddOnlyOnce(t *testing.T) {
p := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(p, []byte(`{"notifications":{"email":"h@example.hu","enabled_events":["backup_failed"]}}`), 0o600); err != nil {
t.Fatal(err)
}
lg := log.New(io.Discard, "", 0)
s, err := Load(p, lg)
if err != nil {
t.Fatal(err)
}
ev := s.Notifications.EnabledEvents
has := func(list []string, x string) bool {
for _, e := range list {
if e == x {
return true
}
}
return false
}
if !has(ev, "backup_failed") || !has(ev, "app_update_undone") || !has(ev, "app_update_held") {
t.Fatalf("the household's choice must be kept and both types added, got %v", ev)
}
// The household opts out of one; a restart must NOT add it back.
s.Notifications.EnabledEvents = []string{"backup_failed", "app_update_held"}
if err := s.save(); err != nil {
t.Fatal(err)
}
s2, err := Load(p, lg)
if err != nil {
t.Fatal(err)
}
if has(s2.Notifications.EnabledEvents, "app_update_undone") {
t.Errorf("a later opt-out must stick — the seed runs once, got %v", s2.Notifications.EnabledEvents)
}
}
+31
View File
@@ -75,6 +75,9 @@ type Settings struct {
// existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append
// re-enabled it on every read — this replaces it).
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
// AppUpdateEventsSeeded (v0.264.0) guards the ONE-TIME add of app_update_undone / app_update_held
// into an existing household's stored prefs (seedAppUpdateEvents).
AppUpdateEventsSeeded bool `json:"app_update_events_seeded,omitempty"`
// HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's
// `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this
@@ -604,6 +607,10 @@ var DefaultEnabledEvents = []string{
"expected_backup_missed",
"expected_dbdump_missed",
"offbox_enlarge_blocked", // 3a-fix (warning-class): remote enlargement refused by the quota gate
// v0.264.0 (`09` §3 decision 15): the household is told ONCE when an update is undone, and when it
// (or its undo) failed and the app is held. On by default; seeded into existing prefs once below.
"app_update_undone",
"app_update_held",
}
// PendingEvent is an event queued for the next Hub push cycle.
@@ -697,6 +704,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
}
s.migrateResticToRsync()
s.seedOffboxEnlargeNotice()
s.seedAppUpdateEvents()
return s, nil
}
@@ -718,6 +726,29 @@ func (s *Settings) seedOffboxEnlargeNotice() {
}
}
// seedAppUpdateEvents runs ONCE (guarded by AppUpdateEventsSeeded, v0.264.0) — the
// seedOffboxEnlargeNotice shape. A household whose stored prefs predate app_update_undone /
// app_update_held gets both appended: they could not have switched off a type that did not exist.
// ADD-ONLY, never removes a choice; persisted, so a LATER opt-out sticks. The hub runs the same one-time
// add on its side — BOTH are needed, because this list is pushed to the hub on every save of the
// notification page and would otherwise take the types away again.
func (s *Settings) seedAppUpdateEvents() {
if s.AppUpdateEventsSeeded {
return
}
s.AppUpdateEventsSeeded = true
if s.Notifications != nil && s.Notifications.EnabledEvents != nil {
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_undone")
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_held")
if s.log != nil {
s.log.Printf("[INFO] [settings] app update events added to the household's notification prefs (one-time, add-only)")
}
}
if err := s.save(); err != nil && s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save the app-update-events seed: %v", err)
}
}
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
// Called once during Load() before the mutex is exposed.
func (s *Settings) migrateResticToRsync() {