controller v0.178.0 — R-88 Part 2: only a positive 'never' fires the valve

MinAgent: 0.105.0. scheduledRunAllowed fired on any nil age; it now requires a
licence from valveLicensed, which grants it for AgeStateAbsent and for a LEGACY
agent, and refuses it for AgeStateUnknown. An unreadable storage no longer
masquerades as a first-ever backup and no longer quiesces apps outside the window.

A missing wire field means legacy, not unknown — deliberately. Treating it as
unknown would stop the valve firing on un-upgraded boxes and starve genuinely new
ones. Degrade logged once; unrecognised future values also map to legacy.

Caught in passing: TieredBackend is satisfied by a RUNTIME assertion, so the
signature change compiled and vetted clean while quiesceBackend silently stopped
satisfying it — which would have degraded every box to the single-tier path with
no error. Added a compile-time witness.

Also corrects the notifier comment that claimed operator-only came from a missing
customerMessages entry; enforcement is hub-side operatorOnlyEvents (hub 0.79.0).
This commit is contained in:
2026-07-27 18:08:56 +02:00
parent ba8bf9cd75
commit 86ea482fc1
12 changed files with 390 additions and 43 deletions
+14 -12
View File
@@ -28,15 +28,15 @@ import (
// tierBackend is a multi-tier fake agent. phases[target] is the phase sequence returned by
// successive BackupStatusFor calls for that tier.
type tierBackend struct {
mu sync.Mutex
tiers []BackupTier
tiersErr error
dueSet map[string]bool
phases map[string][]string
phaseIdx map[string]int
started []string // targets StartBackupFor/StartBackup was called with, in order
mu sync.Mutex
tiers []BackupTier
tiersErr error
dueSet map[string]bool
phases map[string][]string
phaseIdx map[string]int
started []string // targets StartBackupFor/StartBackup was called with, in order
untargetedDue bool
startErrOn string
startErrOn string
// stacks (optional) lets a start sample how many restarts have happened SO FAR — the direct
// way to assert "the app had not resumed when this tier started".
stacks *fakeStacks
@@ -55,10 +55,12 @@ func (b *tierBackend) Tiers(context.Context) ([]BackupTier, error) {
}
return b.tiers, nil
}
func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, error) {
// DueFor returns a nil age with an EMPTY age_state — i.e. the pre-v0.105.0 (legacy) shape, which
// keeps every suite written before R-88 Part 2 asserting exactly the behaviour it always did.
func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, string, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.dueSet[target], nil, nil
return b.dueSet[target], nil, "", nil
}
func (b *tierBackend) StartBackupFor(_ context.Context, target string) (string, error) {
b.mu.Lock()
@@ -360,9 +362,9 @@ type dueErrBackend struct {
errOn string
}
func (d *dueErrBackend) DueFor(ctx context.Context, target string) (bool, *int64, error) {
func (d *dueErrBackend) DueFor(ctx context.Context, target string) (bool, *int64, string, error) {
if target == d.errOn {
return false, nil, fmt.Errorf("simulated due-check failure")
return false, nil, "", fmt.Errorf("simulated due-check failure")
}
return d.tierBackend.DueFor(ctx, target)
}