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