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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user