R-243: offsite_escrow_pending — an operator alarm when off-site is on and the escrow never done (7 days); 09 decisions 177-179; 07 R-899 note
gates / gates (push) Successful in 2m48s
gates / gates (push) Successful in 2m48s
Hub code unreleased; ships with tomorrow's hub release. 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:
@@ -405,6 +405,18 @@ read **only when the storage cannot be read**: a fresh saved copy → not due; a
|
||||
unknown, as before. A storage that answers always wins, so a pruned archive still makes the tier due. Tests:
|
||||
`TestBackupDue_R894_*` (felhom-agent `internal/localapi`).
|
||||
|
||||
**[DESIGN — operator ruling 2026-10-08, `09` §3 decision 177; built on controller and agent main the same day, ships
|
||||
with their next releases — R-899] A daytime press never moves the night's backup. Every night takes its own.** The
|
||||
agent's due-check reads the newest archive on the tier, and a „Mentés most" press is an archive like any other, so a
|
||||
press at 08:49 made the 24 h tier due at 08:49 the next day — after the window closed — and that night had no
|
||||
whole-guest backup, no OS leg and no kernel step (demo-hp, 2026-10-07 → 08). Now the controller keeps a ledger of the
|
||||
last successful press and the last successful SCHEDULED run per tier (`whole-guest-ledger.json` beside the quiesce
|
||||
marker); a tier the agent calls „not due" is still due on the scheduled path when a press came after the last scheduled
|
||||
success and that success is older than tonight's gate opening. Such a tier waits for the window (it never fires the
|
||||
safety valve); a scheduled success inside tonight's window ends it. And a press reaches the agent as
|
||||
`trigger=manual`, after which the agent runs no OS leg (before, a press ran it at once, in the day). Pinned by
|
||||
`TestR899_*` (controller) and `TestAfterPrimaryBackup` (agent).
|
||||
|
||||
**[FACT, 2026-10-04 — agent v0.140.0, `11-os-updates.md` §8.1] The OS leg closes the night.** After the
|
||||
whole-guest backup (the controller drives it, inside [W+2h, W+6h)) ends SUCCESSFULLY on the primary tier, the agent
|
||||
waits 90 s and runs the guest's Debian fast lane — still holding the host-wide heavy-op gate, so it never overlaps a
|
||||
|
||||
@@ -352,6 +352,7 @@ one info line beside `host_crash_restart`; `operatorOnlyEvents`, pinned by
|
||||
| `host_crash_guard_tripped` | error | the guard tripped: the next crash leaves the box OFF | the re-arm → `host_crash_guard_rearmed` (info) | `api/crash_test.go` |
|
||||
| `host_kernel_oops` | warning | a kernel oops this boot (taint D) — the box keeps running | — (once per boot) | `api/crash_test.go` |
|
||||
| `agent_behind` | warning | the box has run an agent OLDER than the vouched one for **7 days** (from when the hub first saw it behind; an unreadable version never counts; nothing vouched → nothing behind) — agents update only by a per-box signed job (R-530), so this is the "nobody signed for this box" alarm (hub v0.135.0) | the box reports the vouched agent (or newer) | `osupdates/r530_agent_alarm_test.go` |
|
||||
| `offsite_escrow_pending` | warning | off-site is ON, the escrow is NOT done (`escrow_state` ≠ `escrowed`) and there has been no successful off-site run for **7 days** — counted from the last successful run, else from the first host report the hub received (an unknown anchor never fires). `offsite_stale` deliberately leaves this state out (pending is the designed onboarding state, `07` §6.1), so until hub main 2026-10-08 a box whose household never did the escrow step never backed up off-site and nothing fired (R-243). Re-sent at most once a week while true; the raise time is persisted, so a hub restart neither re-mails nor forgets (`09` §3 decision 179) | the escrow done, off-site off, or a successful run after the alarm → `offsite_escrow_pending_cleared` (info) | `monitor/offsite_escrow_pending_test.go` `TestR243_*`; `notify/r243_operator_only_test.go` |
|
||||
| `floor_raise_skipped` | warning | a GLOBAL controller floor was raised and one or more boxes keep their own LOWER per-customer floor, so the raise does not move them — ONE mail naming them all (R-604, hub v0.135.0) | — (one per raise) | `web/r604_floor_held_back_test.go` |
|
||||
|
||||
- **`unknown` never alarms** (R-96 rule 3): a probe that could not ask is neither up nor down. An `unknown` report
|
||||
|
||||
@@ -915,6 +915,24 @@ its length, and both fixes cost something the household would notice — operato
|
||||
127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer.
|
||||
*Operator ruling 2026-10-05.* (R-861)
|
||||
|
||||
### 2026-10-08 (07:12) — operator rulings on R-899 and the day's work (recorded before the work)
|
||||
|
||||
177. **R-899: a daytime whole-guest backup never moves the night's backup. Every night takes its own** (option A). A
|
||||
household's „Mentés most" press keeps its own record but does not count for „has tonight's backup run". The 20-hour
|
||||
rule was refused: it would not have caught the 2026-10-07 case (a press at 08:49, a window opening at 04:30).
|
||||
*Operator ruling 2026-10-08 07:12.* Built the same day (controller `internal/quiesce/nightowed.go`; agent: no OS leg
|
||||
after a press), ships with the next releases; `07` §6.1.
|
||||
178. **Today (2026-10-08): progress with other work, without touching tonight's kernel night** (the second one, 8→9, on
|
||||
demo-hp and demo-felhom). No change to those boxes, Tester 1 or the hub's running version; code is built, tested and
|
||||
committed today and released tomorrow. *Operator ruling 2026-10-08 07:12.*
|
||||
179. **R-243's line: `offsite_escrow_pending` fires after 7 days** with off-site ON, the escrow not done and no successful
|
||||
off-site run. Options: 48 h (`offsite_stale`'s line — but that assumes runs are expected, and pending is the designed
|
||||
onboarding state, so a slow household would be reported in its first days); 72 h (`expected_backup_missed` — the same
|
||||
objection); **7 days** (`08` §6.3's line for „a box needing an action nobody took", `os_update_stale` /
|
||||
`agent_behind`, and the off-site whole-guest cadence). Chosen 7 days: a household that does the step in its first week
|
||||
is never reported, and one that does not is reported once a week. *Decided by CC (the brief asked CC to pick) —
|
||||
operator may reverse.* `08` §6.3.
|
||||
|
||||
### 2026-10-07 (evening) — operator rulings on the first kernel night (recorded before the work)
|
||||
|
||||
174. **The household kernel mail text stays as written**, plus a reply address (a `Reply-To` that reaches the operator).
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
## Unreleased (2026-10-08) — an alarm when a box never backs up off-site because its escrow is pending (R-243; `09` §3 decision 179) — ships with tomorrow's hub release
|
||||
|
||||
**Operator action on deploy: none.** Expect ONE `offsite_escrow_pending` mail for **Tester 2** on the first sweep after
|
||||
the deploy: its latest report (2026-10-04) says off-site ON, escrow `pending`, no successful run ever — the state the
|
||||
operator believes it is in (decision 170).
|
||||
|
||||
- **R-243:** `offsite_stale` deliberately ignores a box whose escrow is not `escrowed` (pending is the designed onboarding
|
||||
state), so a household that never does the escrow step — or a box held in `awaiting_recovery_key` — never backed up
|
||||
off-site and nothing fired. New operator-only `offsite_escrow_pending` (warning): off-site ON, escrow not done, no
|
||||
successful off-site run for **7 days** (from the last success, else from the first host report; an unknown anchor never
|
||||
fires). Re-sent at most once a week while true; the raise time is persisted in the settings table (`os_alarm:` key), so
|
||||
a hub restart neither re-mails nor forgets; clears with one `offsite_escrow_pending_cleared` info line.
|
||||
`monitor/offsite_escrow_pending.go`; both types in `notify.operatorOnlyEvents` and the event allowlist. Tests
|
||||
`TestR243_*` (7; red-proved: without the call in `Check` no mail) and `TestR243_EscrowPendingIsOperatorOnly`. `08` §6.3.
|
||||
|
||||
## v0.143.1 — a household's reply reaches the operator; a told kernel that is gone is retried (`09` §3 decisions 174, 176; R-898) (2026-10-07)
|
||||
|
||||
**Operator action on deploy: none.** Deployed with the operator attending (decision 162).
|
||||
|
||||
@@ -2166,6 +2166,9 @@ var allowedEventTypes = map[string]bool{
|
||||
"offsite_proof_empty": true,
|
||||
// R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd.
|
||||
"offsite_snapshots_dropped": true,
|
||||
// R-243 — the hub raises these itself (monitor/offsite_escrow_pending.go); allowlisted like the line above.
|
||||
"offsite_escrow_pending": true,
|
||||
"offsite_escrow_pending_cleared": true,
|
||||
// controller v0.289.0 (decision 69): the customer-chosen deletion of set-aside history is deferred
|
||||
// to the operator — the box's append-only key cannot delete. Operator-only (notify.operatorOnlyEvents).
|
||||
"offbox_abandon_deferred": true,
|
||||
|
||||
@@ -394,6 +394,8 @@ func (oc *OffsiteChecker) Check() {
|
||||
seen[c.CustomerID] = true
|
||||
off := parseOffsite(c.ReportJSON)
|
||||
if off == nil {
|
||||
// R-243: off-site gone → a raised escrow-pending alarm clears.
|
||||
oc.checkEscrowPending(c.CustomerID, nil)
|
||||
delete(oc.fillStates, c.CustomerID) // vanished object (disabled / downgraded) → re-arm
|
||||
delete(oc.staleStates, c.CustomerID)
|
||||
delete(oc.dropStates, c.CustomerID)
|
||||
@@ -434,6 +436,10 @@ func (oc *OffsiteChecker) Check() {
|
||||
}
|
||||
oc.staleStates[c.CustomerID] = newStale
|
||||
|
||||
// R-243 (offsite_escrow_pending.go): the state the staleness signal deliberately leaves out — off-site ON,
|
||||
// escrow never done, so no run ever starts. Persisted, weekly, operator-only.
|
||||
oc.checkEscrowPending(c.CustomerID, off)
|
||||
|
||||
// SNAPSHOT-DROP (R-431). Escalation-only, exactly like the two signals above: one deletion
|
||||
// produces ONE alarm, not one per report cycle. The baseline then moves to the new value, so a
|
||||
// SECOND deletion later is still caught — from the new floor.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── R-243 (2026-10-08) — A BOX THAT NEVER BACKS UP OFF-SITE BECAUSE ITS ESCROW IS PENDING ─────────
|
||||
//
|
||||
// `offsite_stale` deliberately ignores a box whose escrow is not `escrowed`: pending is the designed
|
||||
// onboarding state (`07` §6.1, Tier-3 PAUSED — a run without the household's recovery code would write a
|
||||
// copy nobody could open). That exclusion is right, and it left one state unobserved: a household that
|
||||
// never does the escrow step (or a box held in `awaiting_recovery_key`, the R-241 state) never backs up
|
||||
// off-site, and no alarm of any kind fires — `offsite_stale` needs `escrowed`, the delivery checker skips
|
||||
// the applied shape, and `backup_failed` needs a run that never starts.
|
||||
//
|
||||
// THE SIGNAL. `offsite_escrow_pending` (warning, OPERATOR-ONLY — the household already sees the reminder
|
||||
// on every page; this is the operator's „they have not acted" line): off-site is ON, the escrow is not
|
||||
// `escrowed`, and there has been no successful off-site run for escrowPendingAfter. The clock starts at
|
||||
// the last successful run when there was one (a box that fell back to pending), otherwise at the first
|
||||
// host report the hub received from the customer (when the box became observable). An unknown anchor
|
||||
// never fires: a box that has never sent a host report has its own staleness alarms, and firing here on
|
||||
// a guess is the 2026-07-23 cry-wolf.
|
||||
//
|
||||
// THE LINE: 7 days. `offsite_stale`'s 48 h assumes runs are EXPECTED; here they are not yet, because
|
||||
// the household is in its onboarding step, so the line must be longer. 7 days is `08` §6.3's line for „a
|
||||
// box needing an action nobody took" (`os_update_stale`, `agent_behind`) and the off-site whole-guest
|
||||
// cadence, so a household that does the step in its first week is never reported.
|
||||
//
|
||||
// ONE MAIL PER BOX, THEN QUIET FOR A WEEK; it is re-sent weekly while still true (`08` §6.3's re-send
|
||||
// rule) and CLEARS when the state ends (escrowed, off-site off, or a successful run after the anchor) —
|
||||
// with one info line, recorded and never mailed, so the operator who was told it broke can see it healed.
|
||||
// The raise time is persisted (`os_alarm:` setting), so a hub restart neither re-mails nor forgets.
|
||||
//
|
||||
// Pinned by TestR243_* (offsite_escrow_pending_test.go).
|
||||
const escrowPendingAfter = 7 * 24 * time.Hour
|
||||
|
||||
const (
|
||||
eventEscrowPending = "offsite_escrow_pending"
|
||||
eventEscrowPendingCleared = "offsite_escrow_pending_cleared"
|
||||
)
|
||||
|
||||
func escrowPendingKey(customerID string) string { return "offsite_escrow_pending:" + customerID }
|
||||
|
||||
// escrowPendingAnchor returns when the no-off-site clock started, and whether it is known.
|
||||
func (oc *OffsiteChecker) escrowPendingAnchor(customerID string, off *offsiteReport) (time.Time, bool) {
|
||||
if off.LastSuccess != "" {
|
||||
if t, err := time.Parse(time.RFC3339, off.LastSuccess); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
first, err := oc.store.GetFirstHostReportAt(customerID)
|
||||
if err != nil || first.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return first, true
|
||||
}
|
||||
|
||||
// inEscrowPending is the state: off-site on, escrow not done.
|
||||
func inEscrowPending(off *offsiteReport) bool {
|
||||
return off != nil && off.Enabled && off.EscrowState != "escrowed"
|
||||
}
|
||||
|
||||
// checkEscrowPending raises, re-sends weekly, or clears the signal for one customer. Caller holds oc.mu.
|
||||
func (oc *OffsiteChecker) checkEscrowPending(customerID string, off *offsiteReport) {
|
||||
key := escrowPendingKey(customerID)
|
||||
raised := oc.store.OSAlarmRaised(key)
|
||||
now := oc.now()
|
||||
|
||||
clear := func(why string) {
|
||||
if raised.IsZero() {
|
||||
return
|
||||
}
|
||||
_ = oc.store.SetOSAlarmRaised(key, time.Time{})
|
||||
msg := fmt.Sprintf("Customer %s: off-site backup is no longer held by a pending escrow (%s).", customerID, why)
|
||||
details, _ := json.Marshal(map[string]any{"customer_id": customerID, "reason": why})
|
||||
oc.logger.Printf("[INFO] Offsite escrow pending CLEARED: %s (%s)", customerID, why)
|
||||
if _, err := oc.store.SaveEvent(customerID, eventEscrowPendingCleared, "info", msg, string(details), "hub"); err != nil {
|
||||
oc.logger.Printf("[WARN] Failed to save %s for %s: %v", eventEscrowPendingCleared, customerID, err)
|
||||
return
|
||||
}
|
||||
if oc.onEvent != nil {
|
||||
oc.onEvent(customerID, eventEscrowPendingCleared, "info", msg, string(details), "hub")
|
||||
}
|
||||
}
|
||||
|
||||
if !inEscrowPending(off) {
|
||||
why := "the escrow is done"
|
||||
if off == nil || !off.Enabled {
|
||||
why = "off-site backup is off"
|
||||
}
|
||||
clear(why)
|
||||
return
|
||||
}
|
||||
anchor, ok := oc.escrowPendingAnchor(customerID, off)
|
||||
if !ok {
|
||||
oc.logger.Printf("[DEBUG] Offsite escrow pending: %s — no anchor (no successful run, no host report) — not judged", customerID)
|
||||
return
|
||||
}
|
||||
if !raised.IsZero() && anchor.After(raised) {
|
||||
// A run succeeded after the alarm and the box fell back to pending again: a new episode.
|
||||
clear("a run succeeded after the alarm")
|
||||
raised = time.Time{}
|
||||
}
|
||||
age := now.Sub(anchor)
|
||||
if age <= escrowPendingAfter {
|
||||
return
|
||||
}
|
||||
if !raised.IsZero() && now.Sub(raised) < 7*24*time.Hour {
|
||||
return // quiet for a week
|
||||
}
|
||||
since := "the box first reported"
|
||||
if off.LastSuccess != "" {
|
||||
since = "its last successful off-site run"
|
||||
}
|
||||
state := off.EscrowState
|
||||
if state == "" {
|
||||
state = "not started"
|
||||
}
|
||||
extra := ""
|
||||
if off.State != "" {
|
||||
extra = fmt.Sprintf(" The box declares off-site state %q.", off.State)
|
||||
}
|
||||
msg := fmt.Sprintf("Customer %s: off-site backup is ON but has not run for %s since %s — the escrow step is %s, so no off-site copy is being made.%s "+
|
||||
"The household sees the reminder on every page; this mail is for you: they have not acted.",
|
||||
customerID, age.Round(time.Hour), since, state, extra)
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
"customer_id": customerID, "escrow_state": off.EscrowState, "offsite_state": off.State,
|
||||
"last_success": off.LastSuccess, "anchor": anchor.UTC().Format(time.RFC3339),
|
||||
"after": escrowPendingAfter.String(),
|
||||
})
|
||||
if err := oc.store.SetOSAlarmRaised(key, now); err != nil {
|
||||
oc.logger.Printf("[WARN] Offsite escrow pending: could not record the raise for %s (%v) — not sending, so a restart cannot mail twice", customerID, err)
|
||||
return
|
||||
}
|
||||
oc.logger.Printf("[INFO] Offsite escrow pending: %s (%s since %s)", customerID, age.Round(time.Hour), since)
|
||||
if _, err := oc.store.SaveEvent(customerID, eventEscrowPending, "warning", msg, string(details), "hub"); err != nil {
|
||||
oc.logger.Printf("[WARN] Failed to save %s for %s: %v", eventEscrowPending, customerID, err)
|
||||
return
|
||||
}
|
||||
if oc.onEvent != nil {
|
||||
oc.onEvent(customerID, eventEscrowPending, "warning", msg, string(details), "hub")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-243 — a box whose off-site backup is ON but whose escrow was never done never backs up off-site, and
|
||||
// until now nothing fired. Every test asserts the CONSEQUENCE: does the operator get the mail (an event
|
||||
// through onEvent), how many, and when does it stop?
|
||||
|
||||
type r243Event struct{ typ, sev, msg string }
|
||||
|
||||
func r243Setup(t *testing.T, firstReportAgo time.Duration) (*store.Store, *OffsiteChecker, *[]r243Event) {
|
||||
t.Helper()
|
||||
st := newDiskStore(t) // customer c1, host h1
|
||||
if err := st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetHostReportsReceivedAtForTest("c1", sqliteAgo(firstReportAgo)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var evs []r243Event
|
||||
oc := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) {
|
||||
evs = append(evs, r243Event{typ, sev, msg})
|
||||
}, log.New(io.Discard, "", 0))
|
||||
return st, oc, &evs
|
||||
}
|
||||
|
||||
func r243Count(evs []r243Event, typ string) int {
|
||||
n := 0
|
||||
for _, e := range evs {
|
||||
if e.typ == typ {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// THE ONE THAT MATTERS: on for 8 days, escrow pending, never ran → ONE warning to the operator; a second sweep
|
||||
// sends nothing more. RED-PROOF: remove the oc.checkEscrowPending call from Check → zero events → FAILS.
|
||||
func TestR243_PendingEightDays_OneMail(t *testing.T) {
|
||||
st, oc, evs := r243Setup(t, 8*24*time.Hour)
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50))
|
||||
oc.Check()
|
||||
oc.Check()
|
||||
if n := r243Count(*evs, eventEscrowPending); n != 1 {
|
||||
t.Fatalf("offsite_escrow_pending mails = %d, want exactly 1 (events %v)", n, *evs)
|
||||
}
|
||||
e := (*evs)[0]
|
||||
if e.sev != "warning" || !strings.Contains(e.msg, "escrow step is pending") {
|
||||
t.Fatalf("event = %+v, want a warning naming the pending escrow", e)
|
||||
}
|
||||
if r243Count(*evs, "offsite_stale") != 0 {
|
||||
t.Fatalf("a pending box must not raise offsite_stale (onboarding is not staleness): %v", *evs)
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the 7-day line: the household may still be doing the step — nothing.
|
||||
func TestR243_PendingSixDays_Silent(t *testing.T) {
|
||||
st, oc, evs := r243Setup(t, 6*24*time.Hour)
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50))
|
||||
oc.Check()
|
||||
if len(*evs) != 0 {
|
||||
t.Fatalf("6 days pending must be silent; got %v", *evs)
|
||||
}
|
||||
}
|
||||
|
||||
// Quiet for a week, then re-sent while still true; a hub restart in between neither re-mails nor forgets.
|
||||
func TestR243_WeeklyResendAndRestartSafe(t *testing.T) {
|
||||
st, oc, evs := r243Setup(t, 8*24*time.Hour)
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50))
|
||||
oc.Check()
|
||||
|
||||
// A hub restart: a new checker over the same store.
|
||||
oc2 := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) {
|
||||
*evs = append(*evs, r243Event{typ, sev, msg})
|
||||
}, log.New(io.Discard, "", 0))
|
||||
oc2.Check()
|
||||
if n := r243Count(*evs, eventEscrowPending); n != 1 {
|
||||
t.Fatalf("after a restart: %d mails, want still 1", n)
|
||||
}
|
||||
oc2.now = func() time.Time { return time.Now().Add(6 * 24 * time.Hour) }
|
||||
oc2.Check()
|
||||
if n := r243Count(*evs, eventEscrowPending); n != 1 {
|
||||
t.Fatalf("6 days after the mail: %d mails, want still 1 (quiet for a week)", n)
|
||||
}
|
||||
oc2.now = func() time.Time { return time.Now().Add(8 * 24 * time.Hour) }
|
||||
oc2.Check()
|
||||
if n := r243Count(*evs, eventEscrowPending); n != 2 {
|
||||
t.Fatalf("8 days after the mail and still pending: %d mails, want 2 (weekly re-send)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// It clears when the escrow is done, with one info line (recorded, never mailed), and the next episode alarms again.
|
||||
func TestR243_ClearsWhenEscrowed(t *testing.T) {
|
||||
st, oc, evs := r243Setup(t, 8*24*time.Hour)
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50))
|
||||
oc.Check()
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "escrowed", "", "", 0, 50))
|
||||
oc.Check()
|
||||
if n := r243Count(*evs, eventEscrowPendingCleared); n != 1 {
|
||||
t.Fatalf("cleared events = %d, want 1 (events %v)", n, *evs)
|
||||
}
|
||||
if !st.OSAlarmRaised(escrowPendingKey("c1")).IsZero() {
|
||||
t.Fatal("the raise record must be cleared")
|
||||
}
|
||||
oc.Check()
|
||||
if n := r243Count(*evs, eventEscrowPendingCleared); n != 1 {
|
||||
t.Fatalf("the clear is one line, not one per sweep: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A box that ran once and then fell back to pending counts from its last SUCCESSFUL run.
|
||||
func TestR243_FellBackToPending_CountsFromLastSuccess(t *testing.T) {
|
||||
st, oc, evs := r243Setup(t, 60*24*time.Hour)
|
||||
last := time.Now().UTC().Add(-3 * 24 * time.Hour).Format(time.RFC3339)
|
||||
saveOffsiteReport(t, st, "c1", `{"enabled":true,"escrow_state":"pending","last_run":"`+last+`","last_status":"ok","last_success":"`+last+`"}`)
|
||||
oc.Check()
|
||||
if len(*evs) != 0 {
|
||||
t.Fatalf("last success 3 days ago → silent; got %v", *evs)
|
||||
}
|
||||
old := time.Now().UTC().Add(-9 * 24 * time.Hour).Format(time.RFC3339)
|
||||
saveOffsiteReport(t, st, "c1", `{"enabled":true,"escrow_state":"pending","last_run":"`+old+`","last_status":"ok","last_success":"`+old+`","state":"awaiting_recovery_key"}`)
|
||||
oc.Check()
|
||||
if n := r243Count(*evs, eventEscrowPending); n != 1 {
|
||||
t.Fatalf("last success 9 days ago and pending → 1 mail; got %v", *evs)
|
||||
}
|
||||
if !strings.Contains((*evs)[0].msg, "awaiting_recovery_key") {
|
||||
t.Errorf("the mail should name the box's declared state; got %q", (*evs)[0].msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Off-site OFF, or already escrowed, never raises this.
|
||||
func TestR243_OffOrEscrowedNeverRaises(t *testing.T) {
|
||||
for _, js := range []string{
|
||||
offsiteJSON(false, "pending", "", "", 0, 50),
|
||||
offsiteJSON(true, "escrowed", time.Now().UTC().Format(time.RFC3339), "ok", 0, 50),
|
||||
} {
|
||||
st, oc, evs := r243Setup(t, 30*24*time.Hour)
|
||||
saveOffsiteReport(t, st, "c1", js)
|
||||
oc.Check()
|
||||
if r243Count(*evs, eventEscrowPending) != 0 {
|
||||
t.Fatalf("report %s raised offsite_escrow_pending: %v", js, *evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No anchor (no host report, no success) → not judged (never a guess-fire).
|
||||
func TestR243_NoAnchor_Silent(t *testing.T) {
|
||||
st := newDiskStore(t)
|
||||
var evs []r243Event
|
||||
oc := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) { evs = append(evs, r243Event{typ, sev, msg}) }, log.New(io.Discard, "", 0))
|
||||
saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50))
|
||||
oc.Check()
|
||||
if len(evs) != 0 {
|
||||
t.Fatalf("no anchor must not fire: %v", evs)
|
||||
}
|
||||
}
|
||||
@@ -703,6 +703,10 @@ var operatorOnlyEvents = map[string]bool{
|
||||
// the snapshots still hold the data and telling a customer "your backups were deleted"
|
||||
// would be wrong on the usual reading.
|
||||
"offsite_snapshots_dropped": true,
|
||||
// R-243 (2026-10-08): off-site ON, the escrow never done, no off-site copy for 7 days. The household already sees
|
||||
// the reminder on every page; this is the operator's „they have not acted" line. Listed in the SAME commit.
|
||||
"offsite_escrow_pending": true,
|
||||
"offsite_escrow_pending_cleared": true,
|
||||
// v0.127.0 (decisions 68–69, R-820/R-822). The off-site key registrar, its daily check and the
|
||||
// clean-up window: custody facts about key lines and fingerprints — the household can take no
|
||||
// action on any of them. Listed in the SAME commit that mints them.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package notify
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-243: the escrow-pending alarm and its clear line are the operator's — the household already sees the reminder
|
||||
// on every page, and a missing customerMessages entry would mail them raw operator English. Red-proof: delete either
|
||||
// line from operatorOnlyEvents and this fails naming it.
|
||||
func TestR243_EscrowPendingIsOperatorOnly(t *testing.T) {
|
||||
for _, e := range []string{"offsite_escrow_pending", "offsite_escrow_pending_cleared"} {
|
||||
if !operatorOnlyEvents[e] {
|
||||
t.Errorf("%s is not operator-only", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user