diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 3bcd5b1..5b41e1f 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,27 @@ +## v0.91.1 — observation may only WIDEN a tier's window, never tighten it (2026-08-03, R-86 Part 2) + +**Found by checking v0.91.0 against the live box before trusting it, not by review.** demo-felhom's +offsite tier holds two retained snapshots — `2026-07-27T19:55:41Z` and `2026-07-28T04:49:43Z` — +**8 h 54 m apart**, because one is a healing artefact and the other a real weekly run. The mean-gap +estimator therefore reads a **weekly** tier as nine-hourly: ×4 gives 36 h, the 7-day floor lifts it to +168 h, and a weekly tier proved weekly reaches ~8.25 days of proof age. **The false alarm this whole +task exists to prevent would have returned within a week, on the box it had just shipped to.** + +`restoreProvenWindow` now takes `max(observed, declared)`. Observation refines a tier's rhythm +**upward** and is ignored downward, which is right on its own terms and not merely cautious: a gap +SHORTER than the declared rhythm is routine and means nothing — a retry, a manual run, a heal, a +catch-up after an outage — while a gap LONGER than it is real information, saying this tier genuinely +receives archives less often than the model assumes and its window must widen or it alarms. + +**The cost, stated rather than hidden:** a tier that truly runs faster than its declared rhythm gets a +wider window than it strictly needs, i.e. a slower `restore_test_stale` signal. That is the right +direction for a signal whose message is *"unverified"*. *"Broken now"* is `restore_test_failed`, which +is immediate and untouched. + +Three live-derived cases added to `TestRestoreProvenWindow_Contract`, including the exact 8 h 54 m +gap measured on the box; red-proved by restoring the tighten-too branch +(`window(pbs, observed=8h54m) = 168h, want 288h`). + ## v0.91.0 — a tier's staleness window learns the tier's own rhythm (2026-08-03, R-86 Part 2) **This ships WITH the agent's v0.121.0, not after it.** The agent now proves a tier once per ARCHIVE diff --git a/hub/internal/monitor/restoretest.go b/hub/internal/monitor/restoretest.go index dc9db7e..009bc0f 100644 --- a/hub/internal/monitor/restoretest.go +++ b/hub/internal/monitor/restoretest.go @@ -99,9 +99,26 @@ func declaredArchiveInterval(tier string) time.Duration { // this tier, so the declared rhythm is used. That fallback matters most for exactly the tier this // task is about: a fresh box with a weekly offsite tier has one snapshot and no observable // interval, and falling back to the FLOOR there would recreate the false alarm. +// +// OBSERVATION MAY ONLY WIDEN, NEVER TIGHTEN — and this is not caution, it is a live measurement. +// On demo-felhom (2026-08-03) the offsite tier's two retained snapshots are `2026-07-27T19:55:41Z` +// and `2026-07-28T04:49:43Z`: **8 h 54 m apart**, because one is a healing artefact and the other a +// real weekly run. A mean-gap estimate therefore reads a WEEKLY tier as nine-hourly, ×4 gives 36 h, +// the floor lifts it to 7 days — and a weekly tier proved weekly reaches ~8.25 days of proof age, so +// the false alarm this whole task exists to prevent would have returned within a week, on the very +// box it shipped to. +// +// The asymmetry is right on its own terms too. A gap SHORTER than the declared rhythm is routine and +// means nothing — a retry, a manual run, a heal, a catch-up after an outage. A gap LONGER than the +// declared rhythm is real information: this tier genuinely receives archives less often than the +// model says, and its window must widen or it alarms. So observation refines the rhythm upward and +// is ignored downward. The cost is stated plainly: a tier that truly runs FASTER than its declared +// rhythm gets a wider window than it strictly needs, i.e. a slower stale signal. That is the right +// direction for a signal whose message is "unverified" — "broken NOW" is `restore_test_failed`, and +// that one is immediate and unaffected. func restoreProvenWindow(tier string, observed time.Duration, observedOK bool) time.Duration { interval := declaredArchiveInterval(tier) - if observedOK && observed > 0 { + if observedOK && observed > interval { interval = observed } w := time.Duration(restoreProvenGenerations) * interval diff --git a/hub/internal/monitor/restoretest_test.go b/hub/internal/monitor/restoretest_test.go index 43a31a4..c999565 100644 --- a/hub/internal/monitor/restoretest_test.go +++ b/hub/internal/monitor/restoretest_test.go @@ -399,6 +399,15 @@ func TestRestoreProvenWindow_Contract(t *testing.T) { {"unobservable local falls back to its declared rhythm", "local", 0, false, restoreProvenWindowFloor}, {"unobservable pbs falls back WIDE, not to the floor", "pbs", 0, false, restoreProvenWindowCap}, {"a nonsense zero interval is ignored", "pbs", 0, true, restoreProvenWindowCap}, + // MEASURED ON THE LIVE BOX, and the reason observation may only WIDEN. demo-felhom's two + // retained PBS snapshots sit 8h54m apart (one is a healing artefact), so a mean-gap estimate + // reads a WEEKLY tier as nine-hourly. Taking that at face value gives 4x9h = 36h → the 7-day + // floor → and a weekly tier proved weekly (~8.25d of proof age) alarms within a week of this + // shipping, on the box it shipped to. + {"a short observed gap must NOT tighten a weekly tier", "pbs", 8*time.Hour + 54*time.Minute, true, restoreProvenWindowCap}, + {"a short observed gap must not tighten the host tier either", "local", 30 * time.Minute, true, restoreProvenWindowFloor}, + // ...but a tier that genuinely runs SLOWER than its declared rhythm still widens. + {"a genuinely slower tier widens", "local", 4 * 24 * time.Hour, true, restoreProvenWindowCap}, } // The relationship Part 1 depends on: a weekly tier's window must be WIDER than a daily tier's, // or proving weekly (which is now correct behaviour) alarms on itself.