R-901: a deleted customer's audit rows go 1 year after the deletion (hub, unreleased); ep0-copy removal job written, not installed (decision 181); both times in the privacy draft
gates / gates (push) Successful in 4m9s

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 09:54:39 +02:00
parent 97c260c56b
commit 05becb04d0
10 changed files with 463 additions and 7 deletions
+8 -1
View File
@@ -1,9 +1,16 @@
## 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
## 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); a deleted customer's audit rows go after 1 year (R-901; decision 181) — 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-901 (operator ruling 2026-10-08 09:04, decision 181):** after a customer is DELETED, its `events` and
`notification_log` rows are deleted 1 year after the deletion — a new daily step in `pruneAll`
(`store.PruneDeletedCustomerAudit`). A deletion is a `customer_resets` journal row with leg `customer_delete` = ok and
a completion stamp (a RESET is not one); only rows created at or before the completion go, so a re-used customer id
keeps what it made since; the journal row stays. Note: `events` are pruned for every customer at `max_days` (90) as
before, so the year bites `notification_log`. Test `TestR901_DeletedCustomerAuditGoesAfterOneYear` (red-proved: with
the step returning early, `deleted events=0 notification_log=0, want 2 and 2`).
- **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
+6
View File
@@ -1029,6 +1029,12 @@ func pruneAll(s *store.Store, maxDays int, logger *log.Logger) {
} else if n > 0 {
logger.Printf("[INFO] Pruned %d stale app issues", n)
}
// R-901 (`09` §3 decision 181): a deleted customer's audit rows go 1 year after the deletion.
if ev, nl, err := s.PruneDeletedCustomerAudit(time.Now().UTC(), store.DeletedCustomerAuditKeep); err != nil {
logger.Printf("[ERROR] Prune deleted customers' audit rows: %v", err)
} else if ev+nl > 0 {
logger.Printf("[INFO] Pruned a deleted customer's audit rows (older than 1 year after the deletion): events=%d notification_log=%d", ev, nl)
}
}
// runUnsealBoxSecrets is `felhom-hub -unseal-box-secrets` (R-879 roll-back). It installs the sealing key
@@ -0,0 +1,70 @@
package store
import (
"encoding/json"
"fmt"
"time"
)
// R-901 (operator ruling 2026-10-08 09:04, `09` §3 decision 181): after a customer is DELETED, the hub's audit rows for
// it — `events` and `notification_log` — are kept 1 year, then deleted.
//
// The delete cascade (web/customer_delete.go) deliberately leaves both tables („the audit trail outlives every
// lifecycle tier"), and nothing ever pruned `notification_log`; `events` are pruned for EVERY customer at
// retention.max_days (90 on the live hub), so in practice the year bites `notification_log`. Both are covered here so the
// rule holds if max_days is ever raised.
//
// WHICH customers: the ones whose delete cascade COMPLETED — a `customer_resets` journal row with leg
// `customer_delete` = `ok` and a `completed_at` stamp (a RESET has no such leg and is not a deletion). WHICH rows: only
// those created AT OR BEFORE that completion — a customer id re-used after the deletion keeps everything it made since.
// The journal row itself stays (F-14 provenance: an id and timestamps, no personal data).
//
// Pinned by TestR901_* (deleted_customer_audit_test.go).
const DeletedCustomerAuditKeep = 365 * 24 * time.Hour
// PruneDeletedCustomerAudit deletes the events and notification_log rows of customers whose deletion completed before
// now-keep. Returns the rows deleted per table.
func (s *Store) PruneDeletedCustomerAudit(now time.Time, keep time.Duration) (events, notifications int64, err error) {
rows, err := s.db.Query(`SELECT customer_id, completed_at, legs_json FROM customer_resets WHERE completed_at IS NOT NULL`)
if err != nil {
return 0, 0, err
}
type del struct {
id string
at time.Time
}
var dels []del
for rows.Next() {
var id, at, legs string
if err := rows.Scan(&id, &at, &legs); err != nil {
rows.Close()
return 0, 0, err
}
m := map[string]string{}
if json.Unmarshal([]byte(legs), &m) != nil || m["customer_delete"] != "ok" {
continue
}
t := parseSQLiteTime(at)
if t.IsZero() || now.Sub(t) <= keep {
continue
}
dels = append(dels, del{id, t.UTC()})
}
rows.Close()
for _, d := range dels {
cutoff := d.at.Format("2006-01-02 15:04:05")
r, err := s.db.Exec(`DELETE FROM events WHERE customer_id = ? AND created_at <= ?`, d.id, cutoff)
if err != nil {
return events, notifications, fmt.Errorf("PruneDeletedCustomerAudit %s: events: %w", d.id, err)
}
n, _ := r.RowsAffected()
events += n
r, err = s.db.Exec(`DELETE FROM notification_log WHERE customer_id = ? AND created_at <= ?`, d.id, cutoff)
if err != nil {
return events, notifications, fmt.Errorf("PruneDeletedCustomerAudit %s: notification_log: %w", d.id, err)
}
n, _ = r.RowsAffected()
notifications += n
}
return events, notifications, nil
}
@@ -0,0 +1,76 @@
package store
import (
"testing"
"time"
)
// R-901: a deleted customer's events and notification_log rows go 1 year after the deletion — and nothing else goes.
// RED-PROOF: return early from PruneDeletedCustomerAudit (the pre-R-901 state: nothing prunes notification_log) →
// the c-old rows survive → this FAILS on the first assertion.
func TestR901_DeletedCustomerAuditGoesAfterOneYear(t *testing.T) {
s := newTestStore(t)
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
ts := func(d time.Duration) string { return now.Add(-d).Format("2006-01-02 15:04:05") }
day := 24 * time.Hour
journal := func(cid, legs string, completedAgo time.Duration) {
if _, err := s.db.Exec(`INSERT INTO customer_resets (customer_id, started_at, completed_at, legs_json) VALUES (?, ?, ?, ?)`,
cid, ts(completedAgo+time.Minute), ts(completedAgo), legs); err != nil {
t.Fatal(err)
}
}
row := func(cid string, ago time.Duration) {
if _, err := s.db.Exec(`INSERT INTO events (customer_id, event_type, severity, message, created_at) VALUES (?, 'x', 'info', 'm', ?)`, cid, ts(ago)); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`INSERT INTO notification_log (customer_id, event_type, severity, message, status, created_at) VALUES (?, 'x', 'info', 'm', 'sent', ?)`, cid, ts(ago)); err != nil {
t.Fatal(err)
}
}
deleted := `{"hosts":"ok","residue":"ok","customer_delete":"ok"}`
journal("c-old", deleted, 400*day) // deleted 400 days ago → its audit rows go
journal("c-recent", deleted, 100*day) // deleted 100 days ago → kept
journal("c-reset", `{"hetzner":"ok","pbs":"ok","db_purge":"ok"}`, 400*day) // a RESET, not a deletion → kept
row("c-old", 500*day)
row("c-old", 401*day)
row("c-old", 10*day) // the id re-used AFTER the deletion → kept
row("c-recent", 200*day)
row("c-reset", 500*day)
row("c-live", 900*day) // never deleted → kept (events' own 90-day prune is not this function's business)
ev, nl, err := s.PruneDeletedCustomerAudit(now, DeletedCustomerAuditKeep)
if err != nil {
t.Fatal(err)
}
if ev != 2 || nl != 2 {
t.Fatalf("deleted events=%d notification_log=%d, want 2 and 2 (c-old's two rows before its deletion)", ev, nl)
}
count := func(table, cid string) int {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE customer_id = ?`, cid).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
for _, c := range []struct {
cid string
want int
}{{"c-old", 1}, {"c-recent", 1}, {"c-reset", 1}, {"c-live", 1}} {
for _, tb := range []string{"events", "notification_log"} {
if got := count(tb, c.cid); got != c.want {
t.Errorf("%s rows for %s = %d, want %d", tb, c.cid, got, c.want)
}
}
}
// The journal row (provenance) stays.
var j int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM customer_resets WHERE customer_id = 'c-old'`).Scan(&j)
if j != 1 {
t.Fatalf("the deletion journal row must stay, got %d", j)
}
// Idempotent.
if ev, nl, _ := s.PruneDeletedCustomerAudit(now, DeletedCustomerAuditKeep); ev+nl != 0 {
t.Fatalf("a second run deleted %d+%d rows, want 0", ev, nl)
}
}