R-204 item 1: a freshly minted reset code works without a restart (v0.198.0)

--print-reset-code runs as a separate process and persists the new code;
the running server's cache was never told, so the code the customer was told
to type was refused until the controller restarted. Nothing said so — during
the 2026-08-04 drill that cost two attempts with an operator present.

effectiveClaimCode now reads through to the persisted state before applying
the settings-vs-config precedence, which is itself unchanged. Read-through,
not a TTL: a TTL would leave a window in which a superseded code still works,
which is worse than the bug. Fails closed on an unreadable state; an absent
file is not an error.
This commit is contained in:
2026-08-05 07:17:13 +02:00
parent f4796e0d00
commit 73b6dbc27d
3 changed files with 307 additions and 7 deletions
+52
View File
@@ -628,6 +628,58 @@ func (s *Settings) GetClaimCode() (hash string, generation int, issuedAt string)
return s.ClaimCodeHash, s.ClaimCodeGeneration, s.ClaimCodeIssuedAt
}
// ReloadClaimCode re-reads the PERSISTED claim-code state from settings.json into the in-process
// cache, so a code minted by ANOTHER PROCESS is visible without restarting this one.
//
// WHY THIS EXISTS (R-204 item 1, v0.198.0). `--print-reset-code` runs as a separate process
// (`docker exec`): it loads settings itself, mints a code, persists it and exits. The running
// server's cache never heard, so it kept validating against the previous hash and the code the
// customer was told to type was refused until the controller restarted. Nothing said so. During the
// 2026-08-04 recovery drill that cost two failed attempts with an operator present; a customer alone
// stops there. THE READ IS THE FIX — it is not a cache refresh for tidiness.
//
// READ-THROUGH, NOT A WATCHER AND NOT A TTL, deliberately (R-204 §8.1). A watcher/signal/background
// reloader is a new failure mode for one stale read. A TTL is worse than the bug: it opens a window
// in which a SUPERSEDED code still works. Pinned by
// web.TestClaimCode_SupersededByASecondMint_RefusedImmediately, whose stated red-proof is exactly
// that TTL. The read happens on the claim path only — see web.effectiveClaimCode — and only while the
// box carries no password, i.e. exactly the gate window.
//
// WHAT IT REFRESHES AND WHAT IT DELIBERATELY DOES NOT: hash/generation/issuedAt only.
// ClaimConsumedGeneration is NOT re-read. This process is its only writer and its in-memory value is
// monotonic; re-reading it could move it BACKWARDS if a save had failed, which would resurrect an
// already-consumed code — the exact widening this fix is not allowed to introduce.
//
// An ABSENT file is not an error: a box before its first save legitimately has no persisted state and
// falls back to the controller.yaml bake. A present-but-unreadable or corrupt file IS an error, and
// the caller fails closed on it.
func (s *Settings) ReloadClaimCode() error {
if s.path == "" {
return nil // no persistence configured (tests) — the cache is all there is
}
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading persisted claim state: %w", err)
}
var onDisk struct {
ClaimCodeHash string `json:"claim_code_hash"`
ClaimCodeGeneration int `json:"claim_code_generation"`
ClaimCodeIssuedAt string `json:"claim_code_issued_at"`
}
if err := json.Unmarshal(data, &onDisk); err != nil {
return fmt.Errorf("parsing persisted claim state: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
s.ClaimCodeHash = onDisk.ClaimCodeHash
s.ClaimCodeGeneration = onDisk.ClaimCodeGeneration
s.ClaimCodeIssuedAt = onDisk.ClaimCodeIssuedAt
return nil
}
// SetClaimCode caches a hub-delivered code state (idempotent by generation — the caller guards).
func (s *Settings) SetClaimCode(hash string, generation int, issuedAt string) error {
s.mu.Lock()