R-204 item 4 (box half): a rebuilt box DECLARES that it needs a credential (v0.199.0)

An absent off-site object has four meanings — never configured, mid-restart, a
transient config read failure, and rebuilt-and-stranded — and the hub cannot tell
them apart. The box can, from two local facts it holds with certainty, so it says
so instead of leaving the hub to deduce it from a silence (operator ruling).

The ACK's identity_blob_present is now recorded on EVERY ACK, before the gates
that used to discard it: on a box with no off-site target the auto-confirm returns
immediately, which is exactly a rebuilt box, so the one fact distinguishing it from
a box that never had off-site backups was thrown away every cycle.

The declaration needs BOTH halves — a fresh data area AND a hub-held recovery
package. Freshness alone is a box that never had off-site backups; dropping that
condition makes the whole fleet ask for credentials, which is what the Scenario B
test exists to catch.

The object carries enabled:false and zero sizes, which is what makes it inert to
the hub's existing fill and staleness checkers and to a pre-upgrade hub. A
configured box's JSON is byte-identical to v0.198.0's.
This commit is contained in:
2026-08-05 10:47:51 +02:00
parent 68f195676b
commit 1214bae0a2
7 changed files with 438 additions and 5 deletions
+25 -2
View File
@@ -39,8 +39,22 @@ type EscrowAutoConfirmer struct {
// Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus).
Flip func() error
// Wipe removes the agent-staged secret (best-effort — the flip is the primary effect).
Wipe func(ctx context.Context) error
Logger *log.Logger
Wipe func(ctx context.Context) error
// RecordPresence persists the ACK's `identity_blob_present` — whether the HUB holds a sealed
// recovery package for this box (v0.199.0, R-204 item 4 / R-193).
//
// WHY IT LIVES HERE, in the auto-confirmer, rather than in its own ACK consumer: this is already
// the ONE place the ACK's escrow object arrives, and it is already wired. A second Reconcile call
// in main.go would be a second wiring point, and this project's count of features built but never
// wired is six. Pinned by TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured and by the
// wiring test.
//
// It is called FIRST, before every gate below, and that ordering is the whole fix: on a box with
// no off-site target `Pending()` and `Escrowed()` are both false and Reconcile returned
// 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
Logger *log.Logger
mu sync.Mutex
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
@@ -76,6 +90,15 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
if es == nil {
return
}
// FIRST, unconditionally — see RecordPresence. Every gate below is allowed to skip the
// auto-confirm; none of them may skip this, because an unconfigured box is exactly the case that
// needs the fact. A record failure is logged and does NOT stop the auto-confirm: the two are
// independent, and swallowing it silently is the shape this project keeps removing.
if c.RecordPresence != nil {
if err := c.RecordPresence(es.IdentityBlobPresent); err != nil {
c.logf("[WARN] [escrow-confirm] could not record the hub's identity-blob presence (present=%v): %v", es.IdentityBlobPresent, err)
}
}
if !c.Pending() {
// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
// blob that does not cover the current password must be surfaced (warn + card flag), while
@@ -0,0 +1,81 @@
package report
import (
"io"
"log"
"testing"
)
// R-204 item 4 / R-193 — the ACK's `identity_blob_present` must be recorded on EVERY ACK, including
// (especially) on a box with no off-site target.
//
// THE DEFECT THIS PINS: Reconcile returns early when the box is neither pending nor escrowed, which
// is exactly a REBUILT box's state — so the one fact that distinguishes it from a box that never had
// off-site backups was discarded on every cycle. The recorder therefore runs BEFORE every gate, and
// the test drives Reconcile itself rather than calling the recorder, because the ordering IS the fix.
func TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured(t *testing.T) {
var recorded []bool
c := &EscrowAutoConfirmer{
// The unconfigured-box shape: neither pending nor escrowed. Every gate below will skip.
Pending: func() bool { return false },
Escrowed: func() bool { return false },
LocalHash: func() (string, bool) { return "", false },
Flip: func() error { t.Fatal("an unconfigured box must never flip"); return nil },
RecordPresence: func(p bool) error { recorded = append(recorded, p); return nil },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: "SHA1"})
if len(recorded) != 1 || !recorded[0] {
t.Fatalf("presence not recorded on an unconfigured box: %v — a rebuilt box cannot learn the hub holds its recovery package", recorded)
}
// It must also record the NEGATIVE, so a customer RESET (the hub losing its escrow row) turns the
// box's declaration back off. A set-only flag would strand the declaration forever.
c.Reconcile(&EscrowStatus{IdentityBlobPresent: false})
if len(recorded) != 2 || recorded[1] {
t.Fatalf("a false presence was not recorded: %v", recorded)
}
}
// A nil ACK escrow object records nothing (an old hub, or no escrow row) — absence of a statement is
// not a statement of absence, and overwriting a known-true with false here would un-declare a genuinely
// stranded box every time an old hub answered.
func TestEscrowConfirm_NilAckRecordsNothing(t *testing.T) {
called := false
c := &EscrowAutoConfirmer{
Pending: func() bool { return false },
Escrowed: func() bool { return false },
RecordPresence: func(bool) error { called = true; return nil },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(nil)
if called {
t.Fatal("a nil ACK escrow object must not record a presence")
}
}
// A recorder FAILURE must be logged, not swallowed, and must not stop the auto-confirm — the two are
// independent concerns and a failed settings write must not also break escrow confirmation.
func TestEscrowConfirm_RecordFailureDoesNotBlockAutoConfirm(t *testing.T) {
flipped := false
c := &EscrowAutoConfirmer{
Pending: func() bool { return true },
Escrowed: func() bool { return false },
LocalHash: func() (string, bool) { return "MATCH", true },
Flip: func() error { flipped = true; return nil },
RecordPresence: func(bool) error { return errRecord },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: "MATCH"})
if !flipped {
t.Fatal("a presence-record failure blocked the escrow auto-confirm — they are independent")
}
}
type recordErr struct{}
func (recordErr) Error() string { return "record failed" }
var errRecord = recordErr{}
@@ -0,0 +1,68 @@
package report
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// TestMainWiresRecordPresence — the seam-discipline test (§9 rule 6).
//
// `RecordPresence` is a nil-able field: an unwired confirmer compiles, every test in this package
// passes, the fleet reports nothing new, and the whole of R-204 item 4 is inert. That is this
// project's most-repeated failure shape — six features built and never wired, one of them an off-site
// restage event that existed and never fired once.
//
// It walks the AST of main.go rather than grepping the file, because a commented-out field still
// contains the string (the lesson from the lifecycle-gate wiring test next door), and it parses with
// comments DROPPED so a commented assignment cannot satisfy it.
func TestMainWiresRecordPresence(t *testing.T) {
const mainPath = "../../cmd/controller/main.go"
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, mainPath, nil, 0) // comments dropped on purpose
if err != nil {
t.Fatalf("parse %s: %v — the wiring of RecordPresence is now unasserted", mainPath, err)
}
found := false
sawConfirmerLiteral := false
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
// Match `report.EscrowAutoConfirmer{...}` (and a bare `EscrowAutoConfirmer{...}`).
name := ""
switch t := lit.Type.(type) {
case *ast.SelectorExpr:
name = t.Sel.Name
case *ast.Ident:
name = t.Name
}
if name != "EscrowAutoConfirmer" {
return true
}
sawConfirmerLiteral = true
for _, el := range lit.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "RecordPresence" {
found = true
}
}
return true
})
// Distinguish "the literal moved" from "the field was dropped" — otherwise a refactor that
// relocated the confirmer would read as a passing test over nothing (the §12 rule: an absent
// thing is not evidence).
if !sawConfirmerLiteral {
t.Fatalf("no EscrowAutoConfirmer composite literal found in %s — did the wiring move? This test can no longer see it", mainPath)
}
if !found {
t.Fatal("EscrowAutoConfirmer is constructed WITHOUT RecordPresence — the box will never learn the hub holds its recovery package, and R-204 item 4 ships inert")
}
}