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

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:
2026-10-08 08:00:08 +02:00
parent de4a8d20ba
commit b119301c6f
10 changed files with 382 additions and 0 deletions
@@ -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).
+15
View File
@@ -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).
+3
View File
@@ -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,
+6
View File
@@ -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)
}
}
+4
View File
@@ -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)
}
}
}