diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b6b17..b0beb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ -## Unreleased (2026-10-20) — the monitoring page's storage labels follow the reader's language (W26); text only +## Unreleased (2026-10-20/21) — the monitoring page's storage labels follow the reader's language (W26); a finished off-site abandonment closes on the hub's word (R-1182) + +- **R-1182 (2026-10-21, with hub v0.163.0):** the hub now purges only the recovery packages that open the set-aside copy + it deleted, and keeps the packages of other stored copies, so `superseded_present` can stay true after a finished + abandonment. The ACK field `escrow.abandon_purge_done` now reaches `report.EscrowStatus`; the confirmer's new hook + `AbandonPurgeDone` (wired in `main.go`) closes the box's declaration. An older hub sends no field → the old rule + holds. Tests `TestR1182_TheHubsPurgeDoneClosesTheDeclaration` (red-proved: the call removed → `hook calls = 0`) and + `TestR1182_MainWiresAbandonPurgeDone`. Found by the Tester-2 rehearsal of 2026-10-20 (felhom.eu `audits/rehearsal-2026-10-20/B6-failures/s16-README.md`, word W26): on the English dashboard the monitoring page showed every storage label and purpose in Hungarian. No release in diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 745389b..df0726c 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1137,7 +1137,14 @@ func main() { // on every ACK and discarded. Wired at the same point as the two above, deliberately — // a second wiring site in this file is how six features got built and never reached. RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256, - Logger: logger, + // R-1182 (hub v0.163.0): the hub finished this box's abandonment purge while it rightly keeps the + // packages of OTHER set-aside copies — close the declaration (superseded_present may stay true). + AbandonPurgeDone: func() { + if backupMgr != nil { + backupMgr.ClearAbandonPurgeIfConfirmed(false) + } + }, + Logger: logger, } // R-314/R-279/R-177 (`09` §3 decision 185): the operator's closed list of actions, carried in the // report reply and run in THIS process (opactions_wiring.go). The result rides the next report; diff --git a/controller/internal/report/escrow_confirm.go b/controller/internal/report/escrow_confirm.go index cdd5d4d..f5775ec 100644 --- a/controller/internal/report/escrow_confirm.go +++ b/controller/internal/report/escrow_confirm.go @@ -36,6 +36,11 @@ type EscrowStatus struct { // package" and simply keeps today's message: an older hub cannot make the screen say anything new. SupersededPresent bool `json:"superseded_present"` SupersededAt string `json:"superseded_at"` + // AbandonPurgeDone (R-1182, hub v0.163.0) — the hub answers THIS box's abandonment declaration: the packages that + // open the deleted set-aside copy are gone. The hub now keeps the packages of OTHER stored copies, so + // SupersededPresent can stay true for good after a finished abandonment; this field is what closes the + // declaration then. Absent on an older hub → false → the old rule (SupersededPresent=false) still closes it. + AbandonPurgeDone bool `json:"abandon_purge_done"` } // EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so @@ -70,6 +75,10 @@ type EscrowAutoConfirmer struct { // immediately, so the one fact that distinguishes a REBUILT box from a box that never had // off-site backups was thrown away on every cycle. nil → not recorded (older wiring, tests). RecordPresence func(present bool) error + // AbandonPurgeDone is called when the ACK says the hub finished THIS box's abandonment purge (R-1182) — the box + // closes its declaration. Wired at the same point as RecordPresence, for the reason given there. nil → the + // declaration closes only on SupersededPresent=false (the pre-R-1182 rule). + AbandonPurgeDone func() // RecordSuperseded persists the ACK's `superseded_present` / `superseded_at` (v0.201.0, R-222) — // whether the hub is ALSO keeping an EARLIER sealed package for this box. Wired here for the same // reason as RecordPresence: this is the one place the ACK's escrow object already arrives, and a @@ -157,6 +166,10 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { c.logf("[WARN] [escrow-confirm] could not record the hub's superseded-package state (present=%v): %v", es.SupersededPresent, err) } } + // R-1182: the hub confirmed this box's abandonment purge. Same place and discipline as the two recorders above. + if es.AbandonPurgeDone && c.AbandonPurgeDone != nil { + c.AbandonPurgeDone() + } // R-241: persist the hash the hub's package covers, with the moment we heard it. Same discipline, // same place, same reason as the two above — and this one is the fact the recovery screen has been // unable to see. A record failure is logged, never swallowed, and never blocks the auto-confirm. diff --git a/controller/internal/report/r1182_abandon_done_test.go b/controller/internal/report/r1182_abandon_done_test.go new file mode 100644 index 0000000..c18558c --- /dev/null +++ b/controller/internal/report/r1182_abandon_done_test.go @@ -0,0 +1,39 @@ +package report + +import ( + "io" + "log" + "testing" +) + +// R-1182 (hub v0.163.0): the hub now keeps the packages of OTHER set-aside copies after an abandonment, so +// superseded_present can stay true for good; `abandon_purge_done` is what closes this box's declaration. The hook +// runs on an unconfigured box too (the declaring box has deleted its set-aside copy; its target may be anything). +// COMPANION RED-PROOF: drop the AbandonPurgeDone call in Reconcile → the first assertion fails. +func TestR1182_TheHubsPurgeDoneClosesTheDeclaration(t *testing.T) { + calls := 0 + c := &EscrowAutoConfirmer{ + Pending: func() bool { return false }, + Escrowed: func() bool { return false }, + AbandonPurgeDone: func() { calls++ }, + Logger: log.New(io.Discard, "", 0), + } + c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, SupersededPresent: true, AbandonPurgeDone: true}) + if calls != 1 { + t.Fatalf("purge-done hook calls = %d, want 1 — the box would declare its abandonment for ever", calls) + } + c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, SupersededPresent: true}) + if calls != 1 { + t.Fatalf("hook ran on an ACK without abandon_purge_done (calls = %d)", calls) + } +} + +func TestR1182_MainWiresAbandonPurgeDone(t *testing.T) { + found, saw := confirmerFieldIsWired(t, "AbandonPurgeDone") + if !saw { + t.Fatal("no EscrowAutoConfirmer literal found in main.go") + } + if !found { + t.Fatal("EscrowAutoConfirmer is built WITHOUT AbandonPurgeDone — a finished abandonment never closes (R-1182)") + } +}