F-CRIT-2 marked SHIPPED + PROVEN-LIVE: NewestArchiveTime now counts only
plausibly-complete entries (measured 1 MiB floor), verified by replaying
campaign fault 2 on demo-hp in both directions.
Files two follow-ups:
- R-99: prune never REMOVES phantoms. It does NOT count them toward keep-last
(dry-run kept 2 real + the phantom), so there is no retention/data-loss bug —
but they accumulate one per aborted upload.
- F-CRIT-1: filed READY-HIGHEST. Campaign 8's other HIGH finding, untouched by
this fix.
GC run 2 (04:44Z, past the atime boundary) reclaimed 0 B, and the cause is the
campaign itself: the datastore is relatime, so the night's three full verifies,
~14 backups, two GC runs and several restores kept refreshing chunk atimes.
Sampled atimes confirm no chunk was ever 24h5m cold. Not a defect - GC made a
correct, deliberate decision both times - but a real operational property:
on a relatime datastore, activity defers reclamation. The spec's intended
experiment therefore could not complete inside the window; recorded as such.
Fault 13 now complete: backup-vs-restore-test single-flight holds, and a full
two-tier backup ran successfully DURING GC run 2 (both artefacts landed, GC
TASK OK, zero .bad chunks). True three-way overlap remains untested.
Final fleet verification clean on all three hosts. Campaign-caused outage
(F-REBOOT, 9m47s on demo-hp) stated plainly in the final-state section.
Tail-of-campaign additions after the Phase D revert (both re-injections declared):
- fault 18 (delete a snapshot mid restore-test): detection PASS, and it ROOT-CAUSES
F-LEAK — a failed restore-test cannot destroy its own scratch guest (403,
missing VM.Allocate; the agent token is pool-scoped and a failed restore never
joins the felhom pool)
- fault 11 (guest reboot mid-backup): new finding F-REBOOT — the backup succeeds
but the guest never comes back; ~9m47s outage until a manual pct start
Two evidence corrections, both self-inflicted tooling errors:
- pgrep -cf <pattern> matches its own ssh command line, which invalidated fault
11's first two injections and put one unsound line in fault 9 (withdrawn; that
finding stands on the controller's own job state)
- ep0 runs Etc/UTC, so its 03:30 prune fires at 05:30 CEST — nearly misread as a
broken prune job
Nine findings now, still two HIGH. Fleet healthy.
Unattended 10h run against demo-felhom, demo-hp and ep0. No production code
changed; findings recorded and ranked, not fixed inline.
8 findings, 2 HIGH — both in the system's ability to report that a backup did
NOT happen:
- F-CRIT-1: an app failing to restart after a quiesce never alarms (invariant
I1 in main.go:1213 is false for the failed-restart path)
- F-CRIT-2: a failed offsite backup leaves a phantom snapshot that resets the
tier's freshness clock (NewestArchiveTime has no completeness check)
Retires several never-validated items, including R-87 (first restic restore
round-trip, byte-verified), the full R-88 backoff ladder, age_state=absent,
and the crash-recovery unquiesce under a real SIGKILL.
peti-felhom untouched; ep0 rollback copy intact; fleet healthy at end.
Seam sweep: TieredBackend was the FIRST, not the only one. BackupArchiveLister
has the identical silent-degrade shape and a worse blast radius (it degrades to
the pre-R-84 in-memory-only behaviour), and no compile-time witness existed in
production code anywhere in either repo. No defect found, so no version bump and
no deploy — the witnesses are guards, proven by breaking a signature and watching
go build fail where it previously passed.
Live outage: age_state=unknown captured on real hardware for the first time, with
demo-felhom's local tier genuinely due throughout — the controller deferred and
zero app stacks were stopped. The R-88 breaker did NOT arm and no
whole_guest_backup_failed travelled, because felhom-pbs was not due; recorded as
conditions-did-not-arise rather than claimed as coverage.
Post-boot: the volume changed device name (sdb->sda) across the reboot and the
mount survived only because fstab uses by-id. That was never tested before.
R-88b closed (agent v0.105.0 + controller v0.178.0) — age_state gives 'unknown'
its own representation, with empty meaning legacy rather than unknown so the
first-backup valve keeps working on un-upgraded boxes.
R-97 note updated: hub v0.79.0 (R-97c) replaced a FALSE operator-only comment
with a real register — the comment claimed a guarantee the code did not provide.
Standing rule 4 (R-96): a recommendation that is not followed gets one line
saying why. Added to the live CLAUDE.md and this versioned copy — the live file
is not in a git repo, so committing to it alone would leave the rule as durable
as the chat it came from.
READY re-ranked: R-95 now leads.
v0.78.0 asserted in a comment that a type with no customerMessages entry cannot
reach a customer. It can: templates.go falls back to the raw message when the
entry is missing, and the only customer gate is prefs.EnabledEvents — pure
configuration. A customer with whole_guest_backup_failed enabled would have been
emailed raw English operator text about a backup they cannot act on. The new test
proves it against the v0.78.0 shape.
operatorOnlyEvents is now an explicit register checked before prefs, logging a
skipped/operator_only row so the skip is visible. NOT implemented as 'missing
customerMessages blocks delivery' — several types rely on that fallback on
purpose. The handler comment now names the real mechanism.
The workspace root /mnt/5_hdd/felhom.eu/git/CLAUDE.md is NOT a git repo — this is
its only version-controlled copy, and it had drifted since 2026-07-19. Committing
the three standing rules to the live file alone would have left them exactly as
undurable as the chat log they came from, which is the whole point of R-96.
R-97 collapsed to its shipped one-liner in ROADMAP and closed in OPEN-ITEMS.
PROMPT-TEMPLATE N.5 now names FOUR coupled artifacts instead of two: the
capability map, ROADMAP, the owning architecture doc (ruled as S-1 in CONTEXT.md
but never reflected in the template CC actually reads, so it bound nobody), and
OPEN-ITEMS.md. Tasks must now report which register rows they opened, closed or
re-ranked.
Ops: R-90 swap done (interim; CX33 still blocked), R-95 mitigation armed but zero
snapshots taken so it moves to WATCHING rather than closed, R-91 gate still not
satisfied. CONTEXT.md datastore path corrected to /mnt/pbs-datastore.
internal/quiesce had no route to the hub at all: three failed whole-guest backups
on 2026-07-27 produced zero events. Hub half of the fix.
whole_guest_backup_failed / _recovered are allowlisted with NO customerMessages
entry. Deliberately not backup_failed/backup_completed — those have customer
Hungarian templates AND sit in demo-felhom's live enabled_events, so reusing them
would email the customer that their backup failed while it is still retrying
behind the R-88 breaker.
The recovery joins recoveredPairedDownTypes because it is severity info and
severityNotifies drops info — otherwise the operator hears it break and never
hears it heal. Its customer leg is pairing-gated and can never fire.
Operator cooldown gains a per-tier dimension from the event details, so one tier
cannot mask another for an hour. Narrow: empty suffix unless a tier is sent, so
no existing event type changes.
R-88 split: Part 1 (the failure breaker) SHIPPED in controller v0.176.0 and live
on both boxes; Part 2 (unknown != never) stays OPEN and is agent-side.
Phase 0 established the root cause at source: newestArchiveOn's (time.Time, bool)
signature cannot represent 'unknown', so a storage read ERROR collapses into a
positive 'no successful backup recorded yet'. The errored and genuine-never paths
are byte-identical on the wire, which is why Part 2 cannot be done controller-side.
R-97: the whole-guest backup tier has no failure signal to the hub at all —
internal/quiesce never imports internal/notify, so three failed backups and three
app-stack outages produced zero backup_failed events. Its only trace was a
customer-tier Hungarian app_start_failed for an app the backup itself had stopped.
Read-only triage found work that was agreed or discovered but never given an id:
R-95 restic offsite credential CAN delete — answers the parallel question R-89
raised and left open. Per-customer subaccounts report readonly=False, the
controller runs forget --prune from the box, and the sftp: backend cannot
express append-only. Storage Box snapshots (snapshot_limit=10, plan=null,
0 used) are server-side and SFTP cannot delete them — an unused zero-code
mitigation.
R-94 hub pins hostInstallVersion 1.19.0 while host-install ships 1.20.0, so a
hub-driven install still gets the pre-R-82 backup default.
R-90 ep0 has no swap at all and OOM'd today; gates R-86.
R-91 the pre-migration 13 GB datastore copy still occupies ep0's root disk.
R-92 PBS-DR gauge granularity. R-93 drill-r50 fixture tension.
R-96 two standing rules agreed in chat and never committed (the third, N.5's
third leg, IS committed at CONTEXT.md:8).
Open state was spread across ROADMAP, CONTEXT.md, four audits/, three runbooks,
per-session REPORT.md files and a chat log. This is the one page to read first:
every row has a state (BLOCKED/READY/WAITING-ON-OPERATOR/WATCHING) and an owner,
and the READY rows are ranked with reasoning.
R-88 is the recommended next task — quiesce's nil-age fail-open stops every app
stack every 5 minutes with no backoff and bypasses the maintenance window, and
its trigger (a PBS read failure) is live given ep0's demonstrated OOM.
CONTEXT.md now records that OPEN-ITEMS.md is authoritative and that REPORT.md is
overwritten per session.
Supervised runbook execution. No code, no version bump.
The felhom-pbs tier had reported `job errors` on EVERY demo-hp backup
since the tier was created on 07-26, while the data landed correctly
every time: `DatastoreBackup` grants Datastore.Backup but not
Datastore.Prune, so the box's keep_last=2 prune was denied.
Operator ruling: retention is a COMMERCIAL attribute owned by the hub;
ep0 executes. Box tokens therefore stay write-only - a compromised box
must not be able to delete its own offsite backups. No grant was widened
and felhom-tenantsync.sh is unchanged (the ruling makes it correct).
Increment 1:
- boxes stop attempting prune. allowPBSPrune is DERIVED
(`!t.Primary && t.KeepLast > 0`), so keep_last: 0 on the PBS tier
disables both the --prune-backups value and the gate in one config
edit, and the tier stays armed. Verified prune_pbs_allowed=false on
both boxes with no tier REJECTED line.
- per-namespace prune jobs on ep0, keep-last 2, daily 03:30 UTC
(05:30 CEST), dry-run gated. demo-hp 3->2, demo-felhom untouched,
chunk count unchanged (prune removes indexes, not chunks).
Write proof CLOSED: 08:25:47 job errors -> 09:37:29 TASK OK, snapshot
2026-07-27T09:37:29Z, chunks 9787->9813, prune step absent entirely.
Driven through POST /api/guest-backup/trigger (the UI path), not
--selftest and not raw vzdump. Hub gauge evidence explicitly NOT
satisfied - the delta is below its 0.1 GB display granularity.
GC scheduled sun 04:30 UTC and deliberately NOT run: every chunk still
carries a fresh atime from the migration copy, so a run today would
reclaim nothing. verify-new enabled per operator ruling, turning an
inert hub alarm live.
Legacy demo-felhom-01 namespace deleted with its two ACL entries and its
token (operator ruling, confirmed twice) so nothing dangles.
R-89 records the target architecture and carries the unanswered parallel
question: does the restic key on storage-box-pool-1 have DELETE rights?
If so the daily app-data tier has the identical exposure and append-only
is the equivalent answer.
ep0 is Etc/UTC, not CEST - corrected in the record.
Corrects two wrong severity readings with evidence from the box and the code.
The PBS outage was ~15 min (07:00-07:18 UTC), caused by a global OOM at 06:58:12:
proxmox-backup-proxy peaked at 3.2G on a 3.8G box and a concurrent 1.9G rsync
tipped it over. Root SSH to that box works from DooPlex via the public IP, not
from felhom-pve via the tunnel IP — the documented path I failed to try first.
R-88: internal/quiesce has NO failure limiter, backoff or breaker; the loop
stopped after three cycles only because PBS recovered. Verified additionally that
scheduledRunAllowed (quiesce.go:476-478) returns true whenever lastAgeSecs is nil,
so the same missing value that makes every poll due also bypasses the time-of-day
gate — the cycles ran outside the [04:30,08:30) window. Fixing the due-verdict
without fixing the nil-age bypass would leave the hole open.
Recorded after the session report was written. The offsite PBS service stopped
listening on 8007 five minutes after this session's 14.46 GB restore-test read
from it; the box is up and the tunnel is healthy, but no SSH key to it exists so
the cause is unestablished — the restore load is a plausible mechanism on a cx23
and is recorded as correlation, not cause.
The agent restart then exposed R-88: an unreachable target reads as 'no backup
exists', so the offsite tier is perpetually due and the controller runs a full
quiesce cycle every ~5 min. Operator ruling: leave it running, it self-heals when
PBS returns and masking it would hide the fault.
The last open item from the datastore relocation. Hub operator UI
(Offsite -> PBS DR) reports felhom-offsite (ep0) at 97.9 GB capacity,
12.6 GB used, 13% full - agreeing with the on-box df (98 G / 13 G / 13%).
The gauge follows the datastore's CONFIGURED PATH, so the relocation
required no hub-side change. RUNBOOK section 10.3 warned that a stale
37.2 GB reading would mean the gauge reads the wrong filesystem and
would be a real bug worth a roadmap item - it does not, and there is
no bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ARoadHBf8rHoscfiqeVZn
Supervised runbook execution. No code change, no version bump.
felhom-offsite moved from ep0's 40 GB root disk (/srv/pbs-felhom) to a
dedicated 100 GB Hetzner Cloud Volume (/mnt/pbs-datastore, ext4 -m 0,
by-id fstab, relatime). Datastore NAME unchanged, so the PBS-DR
descriptors, per-box storage ids, ACLs and namespaces are untouched.
Capacity: 37.2 GB -> 98 GB total, 28.9% -> 13% used, headroom to the 80%
warn 19 GB -> ~65 GB. This CLEARS the R-82 Phase 0 P0.3 STOP. Per-tenant
encryption still precludes cross-customer dedup, so the slope is
unchanged - the volume buys runway, not a better cost model.
Verified: byte totals and chunk counts identical (9748), 7/7 snapshots
across all three namespaces, backup:backup ownership, clean itemised
dry-run, full verify job TASK OK with 0 errors, and a restore round-trip
(source_tier pbs, pass true, mount_parity ok, clean teardown).
Nothing deleted - the original 13 GB stays at /srv/pbs-felhom as the
rollback until a new weekly backup lands. GC deliberately not run.
Three findings recorded:
- the `scratch` datastore points at a non-existent path (pre-existing;
now logs ENOENT every start) - operator decision
- the runbook's S6 guard test proves the wrong proposition:
RequiresMountsFor re-mounts rather than refusing, so the test only
bites when the device is genuinely unavailable (re-run that way, and
the refusal was observed) - amendment recommended
- S11: storage box u629193 has no live backup path, BUT ep0 carries an
enabled sshfs mount unit against it that must be removed before the
box is deleted
Deviations: the volume arrived pre-formatted and mounted; S8 ran on
demo-felhom rather than demo-hp (no SSH key for demo-hp); the window was
contended by a stale in-memory 10-minute restore-test cadence whose
config had already been reverted on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ARoadHBf8rHoscfiqeVZn
Measured on demo-felhom while the offsite PBS service was down: the controller
re-polls /backup/due every ~5 min, still gets 'due' (storage unreachable + cold
store), and runs the FULL quiesce cycle each time — all four customer app stacks
stopped and restarted for a backup that cannot succeed. ~19 s of app downtime per
cycle, unbounded. The first entry called this bounded and event-only; it is an
availability fault.
Observed live on demo-felhom 2026-07-27 07:02:57 UTC: an agent restart while the
offsite PBS service was down produced a doomed vzdump at that tier. R-84's
read-error fallback to the in-memory record is correct alone but empty after a
restart, so "cannot read the storage" resolved to "no backup has ever been
taken" = due. Same class R-81 fixed in the hub, one layer down in the agent:
unreachable must be UNKNOWN, not resolved.
The unattended offsite restore-test on demo-felhom passed: 14.46 GB archive,
duration_s=635.07 (10m35s), then it rotated to the local tier. Persisted state
confirms the credit: {"felhom-pbs": "2026-07-27T06:14:42Z"}.
CORRECTION: I estimated ~2 hours for this restore. It took 10m35s. I derived
the estimate from a download rate measured during the FAILED attempt, which was
running under contention; the real link does ~1.4 GB/min. I then used that wrong
figure to raise a design concern — that the heavy-op gate would block backups
for hours on this box — which at 10 minutes largely evaporates. An estimate
extrapolated from a degraded measurement is not a measurement.
The SPEC's closing risk note is corrected in place, with the original left
visible for the lesson.
R-86 (NEXT, operator ruling 2026-07-27): backup-ALIGNED restore-test scheduling
— test a tier ~1 day after ITS OWN backup. R-85 schedules on a free-running
interval, which cannot express 'the day after the PBS backup': any fixed offset
drifts, so alignment would be luck. Shape: trigger from the tier's own last
successful backup rather than a clock. Interim in force: 302400s (3.5d), which
lands each tier ~weekly — the cadence half of the ruling, not the alignment half.
R-87: the restic app-data offsite tier is NEVER restore-tested. R-85 covers
whole-guest vzdump tiers only; the agent has no restic surface. That is arguably
the tier that matters most — the only one that survives losing the box AND
carries the customer's app data, since the whole-guest snapshot excludes the
bind-mounted drives. Exactly the state PBS was in before R-85.
REPORT.md: the full R-80 -> R-85 arc, including a section on the seven mistakes
I made and the two recurring shapes behind them (inferring behaviour from an
artifact instead of the code that consumes it; reading a result without its exit
code). Records demo-felhom's restore-test as IN FLIGHT at close, with the manual
recovery step if the deferred restart watcher does not complete.
Hub gate green (17 packages, rc=0).
- ROADMAP: R-85 row. Code SHIPPED; rotation NOT YET OBSERVED LIVE, stated as
such rather than written as done.
- Capability map: a new row for UNATTENDED restore-proof, IMPLEMENTED not
PROVEN-LIVE, kept distinct from the R-82 row that a MANUAL selftest earned.
That distinction is the same one the activation-vs-arrival split made.
- 03-host-agent §8: the scheduler covers every tier, oldest-proven first; the
spec is per-run; a restore-test joins the one-heavy-op gate. The safety
properties that must not be re-derived are listed.
- 07: restore-proof recorded as a per-tier property. Doc still NOT ratified.
- 06: corrects S4.1's 'the offsite restore-test now runs unattended' — it
silently stopped being true when local_backup_target was retargeted to 'local',
the SECOND time in that doc that a correct mechanism was broken by its input
changing underneath it.
- CONTEXT + REUSE.
Hub gate green (17 packages, rc=0).
Written against verified state, not assumption. Records three gaps the original
Phase 5 ordering does not cover:
1. hub v0.77.0 is COMMITTED BUT NOT DEPLOYED — manifest pins 0.76.0 and the pod
runs 0.76.0, so the R-85 signal exists only in git. The original Phase 5 only
mentions the agent.
2. R-85 has no ROADMAP row.
3. The agent CHANGELOG says v0.104.0-dev; an ldflags version disagreeing with
the CHANGELOG is the reconciliation problem hub 0.73.2 already caused.
One ordering correction: THE HUB GOES FIRST. Agent v0.104.0 makes the offsite
tier testable; hub v0.77.0 makes a failure audible. Agent-first means rotation
begins with nothing listening — two tiers able to fail silently instead of one,
which is the fault R-85 exists to end. Also drops the retired drill box, so the
rollout is demo-hp -> demo-felhom.
Names the phase's most likely SILENT failure: the new rotation state lands at
/var/lib/felhom-agent/restore-test-state.json and the agent is non-root. If that
is not writable, RecordSuccess warns and continues — a quiet return to one tier
being starved, not a crash.
Flags for operator judgement: demo-felhom's 14.46 GB offsite archive makes its
unattended restore-test a ~2h operation every other day, holding the heavy-op
gate throughout. Ruled when the only measured restore was demo-hp's 4 minutes.
A failed restore-test was a [WARN] line in the ingest handler and nothing else —
no event, no notification, no gauge. True for the LOCAL tier that was already
being tested, so the loudest DR signal this system produces was inaudible.
Rotating tiers without this would only mean two tiers can fail silently
instead of one.
Two signals, deliberately NOT merged:
restore_test_failed (error) — a run completed and did NOT pass
restore_test_stale (warning) — a tier not PROVEN within its interval
Merging them collapses 'your DR is broken' into 'your DR is unverified', and
the second is the one that quietly becomes the first. The staleness wording
says 'unverified, not known-broken' and a test asserts that phrasing.
Anchored per R-81, not re-derived: a never-proven tier on a newborn box is
UNKNOWN, not FAILED, until the window elapses. This family has made the
opposite mistake three times; this monitor was written straight after the third,
so it copies R-81's structure rather than inventing a fourth shape.
restoreProvenStaleAfter = 7d is derived: oldest-first over two tiers at a 24h
cadence proves each ~every 2 days, so 7d tolerates ~3 missed opportunities and
sits inside the 2-week offsite retention.
Per-tier proof comes from the hub's retained host-report window — the agent
reports only its latest run, so the latest report alone cannot answer 'when was
the OTHER tier last proven?'. Reused R-81's mechanism instead of a wire change.
Both types registered in allowedEventTypes (R-77's inert-seam lesson) and
operator-tier only — no customerMessages entry.
FIXED a time bomb I introduced in Slice C: the restart-blind-window test
hard-coded 2026-07-18T18:31:06Z while comparing against the real clock. Harmless
under one 26h threshold; once the offsite tier got an 8-day limit it passed all
day and began failing at 18:31 UTC, exactly 8 days later. Now relative.
Red-proofs B and D observed. Full suite green (17 packages, rc=0).
Restore round-trip on demo-hp: pass=true, verified=boot+running,
mount_parity=ok, source_tier=pbs (the v0.100.0 fix — the earlier attempt said
'local' and died at 600s), 4m5s restore+boot+verify+teardown, clean teardown
with no 403 and no leak. That last point confirms 06's reading that the
teardown 403 was a phantom, and corrects my earlier framing of it as a standing
privilege gap.
Multi-tier quiesce driven through the REAL UI endpoint (authed+CSRF):
exactly ONE stop/start pair with BOTH backups inside it, local-first/PBS-last,
app quiesced through the non-last tier, early resume on the last tier's
snapshot. Total downtime 1m27s for both tiers; app healthy after.
Capability map row upgraded IMPLEMENTED -> PROVEN-LIVE, kept distinct from the
DR-tier row above which proves ACTIVATION not ARRIVAL. Remaining gaps recorded:
the SCHEDULED restore-test still only selects the primary tier (manual path
proven, unattended not), and the hub infers cadence from storage type.
R-84 resolved by asking the STORAGE rather than persisting the store: ground
truth, so a pruned archive correctly stops counting where a persisted record
would keep claiming a backup that no longer exists. Proven live on both boxes
with the in-memory store cold.
demo-hp's FIRST EVER offsite backup landed (4.25 GB) — the R-82 finding closed
on the box where it was worst. Controller v0.175.0 deployed to both boxes.
Slice D.1 — host-install 1.20.0: a FRESH box defaults to local-daily +
offsite-weekly (felhom-pbs, 604800s, keep_last=2). setdefault semantics proven
both ways: fresh gets the tier, an UPGRADE preserves the existing backup block
verbatim — so an in-place upgrade can never silently start writing to an
offsite datastore. Existing boxes are migrated explicitly.
Slice E:
- 07-backup-architecture.md: honest status header per CONTEXT ruling S-2, with
an explicit STALE-outside-the-PBS-tier verdict (the controller tiers were last
verified 41 controller versions ago). The PBS row claimed 'PBS on DooPlex'
(the retired spike store) with no cadence; it now names felhom-pbs ->
felhom-offsite on ep0 over wg-felhom, weekly, keep_last=2. NOT marked
ratified — that is Viktor's review of the section 10 list. Discharges R-83.
- 06-offsite-connectivity.md: the target-split remaining-work note collapsed
(shipped), and records HOW S4.1's tier-aware timeout silently regressed — the
mechanism was never removed, its INPUT changed when local_backup_target was
retargeted to 'local'. Also notes S4.1 already diagnosed the teardown 403 as a
phantom (a timeout consequence, not an ACL gap).
- capability map: new row for recurring offsite backups actually LANDING, as
distinct from the existing row proving ACTIVATION. IMPLEMENTED, not
PROVEN-LIVE — the restore round-trip has not completed under the fixed code.
- ROADMAP: R-82 SHIPPED with its remaining gate named, R-83 DISCHARGED, R-84
left open.
- CONTEXT + REPORT: the arc, including the mid-arc correction I had to make.
R-81 merged every backup signal into one 'newest' against a single 26h limit.
backupStaleAfter's own comment recorded why that stops being right under a
weekly offsite tier. Each tier is now judged against its own threshold;
R-81's structure (three verdicts, anchored absence, distinct reasons) and its
boundary test are preserved intact.
- offsiteBackupStaleAfter = 8d (7d cadence + headroom); backupStaleAfter keeps
26h and now names the HOST tier only
- splitTiers / assessTier / newestBackupEvidenceByTier
Slice-A.4 rule implemented: a PBS-targeted vzdump appears in BOTH arrays, so
classification is by TARGET TYPE (target_id -> storage_targets[].name -> type),
never by array membership — otherwise a PBS backup makes a stale host tier look
fresh. storage_targets is used rather than pbs_dr.storage_id because the latter
is null on a box with a PBS storage but no DR descriptor.
A tier is only judged when the box HAS it, else every box without an offsite
tier would alarm once the anchor elapsed — R-81's mistake one level down. With
neither tier identifiable (old agent) the pre-Slice-C path runs unchanged.
Intended behaviour change: a 30h offsite snapshot no longer alarms. Three
fixtures asserted the merged threshold; each still asserts an alarm at the
correct limit. No assertion was weakened.
RECORDED LIMITATION: the hub infers 'PBS => weekly' from storage type.
defaultBackupTarget is felhom-pbs, so a box that never sets local_backup_target
would run PBS as its DAILY tier and be judged against 8 days — 7 days of
blindness. No box is in that shape today; the real fix is the agent reporting
per-tier cadences. Own task.
Red-proof observed. Replayed live: demo-felhom OK, demo-hp UNKNOWN (defers
correctly), drill-r50 MISSED (true positive). No customer email would be sent.
Per the operator's 2026-07-26 ruling: datastore growth deferred, R-82 proceeds.
Records the measurements so the constraint is written down rather than carried
in a session: 37.2 GB total / 10.8 GB used, no cross-customer dedup (per-tenant
encryption keys), 80% warn reached at roughly the second additional customer,
and the pvesm 0/0/0 reporting artifact that means operators must read fill from
the hub gauge. Also records what the tier does and does not carry, and the
conditional on the P0.1 weekly verdict (Tier-3 offsite must be healthy).
Doc NOT marked ratified — that stays Viktor's review (R-83).
Read-only gate. No code, no backup triggered, no config changed.
P0.1 weekly CONFIRMED: the only 7-day-exposed state is the non-SMB half of
settings.json (storage_paths, app_backup toggles, notification prefs,
password_hash, launcher token) — none catastrophic, all recoverable. The two
items that would have overturned it, encryption.key and the offbox
credentials, are STABLE files unchanged since first boot, so a 7-day-old copy
is byte-identical. Everything referentially coupled to app state is carried
daily by Tier-1/2/3, and Tier-3 offsite was verified running and ok on both
production boxes. CONDITIONAL: drill-r50 has offsite:null, so the verdict does
not hold there.
P0.2 RESOLVED, benign: PBS returns HTTP 200 with zeroed usage to the
namespace-scoped token (DatastoreBackup, not Datastore.Audit), so PVE prints
0/0/0. Ground truth via the hub ep0 df op: 28.9% of 37.2 GB. Writes work —
the 07-18 snapshot is owned by this token, 9.74 GB, verify ok.
P0.3 STOP: 37.2 GB datastore, 10.8 GB used, 80% alert at 29.8 GB. Encrypted
per-tenant backups do not dedup across customers. Current 3 boxes project to
~15-21 GB weekly keep-3; each additional customer costs ~5-10 GB, so the 80%
alert fires at roughly the SECOND additional customer — inside the alpha
horizon. Needs a ruling on datastore size / retention before recurring writes.
Third instance of one class (hub v0.12.0, v0.73.0, this), fixed as a class.
On 2026-07-26 03:00 UTC expected_backup_missed fired on demo-felhom, demo-hp
and drill-r50 at once; the demo-felhom one reached the CUSTOMER channel
claiming "newest backup is 176h0m0s old". Nothing was wrong — three vzdump
archives were on disk. Cause: the agent backup store is in-memory, so the
R-50 fleet restart emptied `backups` until the next run, and the hub read
empty as "no backup exists".
- assessBackupFreshness returns OK/UNKNOWN/MISSED instead of `missed bool`;
absence is UNKNOWN until it outlives an anchored window. Still pure.
- store.GetHostReportsSince + monitor.newestBackupEvidence read the hubs own
retained history (bounded 7-day lookback, early-exit on fresh evidence) —
"when did I last SEE evidence of a backup?" The anchor was free: the hub
already retains 90 days. No agent change, no new persisted state.
- store.GetFirstHostReportAt anchors absence at first contact, reusing the
existing 26h threshold as the grace (no new knob, the v0.73.0 shape).
- Deferrals logged + counted; reason strings kept distinct.
- backupStaleAfter untouched; landmine recorded (a weekly PBS snapshot would
alarm six days in seven) and owned by R-82.
Tests 493->508. Red-proofs A/B/C observed and restored; A reproduces the live
message verbatim. Replayed the real 03:00 reports (600/417/77 rows): all
three now silent.
Source: documentation/audits/DIAG-backup-missed-2026-07-26.md
Read-only diagnostic. No fix applied.
Verdict: FALSE ALARM for demo-felhom + demo-hp, true-but-premature for
drill-r50. Fired ONCE (2026-07-26 03:00 UTC), not nightly. No real
external customer notified.
Root cause: felhom-agent/internal/backup/store.go is in-memory by design;
an agent restart empties the host-report 'backups' array until the next
backup runs. The R-50 island migration restarted the fleet on 2026-07-25
12:44 UTC, opening an ~18h blind window that the 03:00 UTC deadline check
fell into. Hub's assessBackupFreshness reads the empty array as 'no
backup exists'.
Separate real finding: the PBS/offsite-DR tier has no schedule at all
(local_backup_target=local, no jobs.cfg) — demo-felhom has one snapshot
from 07-18, demo-hp has zero since pbs_dr was applied 07-21.
The allowlist entry is REQUIRED, not cosmetic: handleEvent 400s an unknown
event_type, so controller v0.173.0's new drift alert would be silently inert
without it. Shipped with the controller that emits it.
Docs:
- RUNBOOK-local-api-endpoint-drift.md — how to repair a drift, including the
step everyone will want to skip (establish which value is CORRECT from what
the agent is actually bound to, rather than assuming bootstrap.json wins) and
what success looks like (SILENCE, not a "recovered" line, because a fresh
controller's healthy first observation is not logged). Records both
2026-07-26 repairs.
- ROADMAP: R-77 shipped; R-78 the local_api authority ruling, with the
clobber-a-working-channel risk spelled out in BOTH directions so it is not
resolved opportunistically; R-79 the whole-surface English-strings sweep;
R-80 expected_backup_missed, flagged as likely outranking R-77 because 7.3
days of stale backup materially exceeds the ~1.5-day channel outage, so the
causal link the DIAG hedged on cannot be the whole story.
- Capability map: note against the drive-wizard row (every agent-backed
capability rides this channel) that a silent drift class is now detected.
NO row status flips — detection is not prevention.
Read-only diagnostic; nothing restarted, reconfigured or deployed.
CAUSE (H2, supported): the island migration rewrote bootstrap.json
(169.254.253.1:8443) but the controller reads controller.yaml, which still
holds the pre-island LAN address on both boxes -- mtimes 2026-07-21 and
2026-07-23, i.e. days BEFORE the migration. ensureLocalAPI
(bootstrap.go:254) returns early when local_api.endpoint is non-empty, so it
fills a MISSING endpoint and never reconciles a STALE one. The agent no longer
binds the LAN address, hence "connection refused".
H1 refuted: every island leg is healthy on both hosts -- agent active and bound
169.254.253.1:8443, vmbr9 up, guest eth1 up, TCP from the guest to the island
is OPEN. The island works; nothing is using it.
H3 refuted: hub-persisted events put the first alarm at 2026-07-25 12:44/12:46,
~17.5h before the v0.172.0 deploy. Today's re-fires are restart artifacts --
channel state is in-memory by design.
H4 refuted: felhom-agent is active on both hosts.
Also: the logged "up->down" is an ARTIFACT -- the debounce branch seeds an
unseeded state to "up", so the channel was born-down and has never been up.
Symptom B confirmed: EffectiveProtected gates cloudflared on a token predicate
but samba on smb.Enabled alone, so sharing-without-password reports FAIL for a
deliberately-undeployed stack. The function's own doc comment asserts the parity
that is broken. Caused by leg 4 of the R-75 task. Blast radius: zero emails so
far, but demo-hp flipped ok->fail and the health_critical path has reached the
CUSTOMER channel historically. The English issue strings are whole-surface, not
a one-off.
Fixes described, none applied.
New documentation/controller/import-and-data-paths.md: the canonical import root
(and why it is NOT a registered StoragePath), the three data_paths roles, the
Fork-3 validation asymmetry, the class-driven copy rule, and the seven
invariants a future change must not break.
Capability map "File access via browser" — status DELIBERATELY UNCHANGED. The
drop-zone now has its own FileBrowser source and the app page carries a deep
link, both verified live, but nothing drove the FileBrowser HTTP UI (no browser
on DooPlex), so the row's standing "browse is exercised in no doc" caveat still
holds and PROVEN-LIVE remains unearned.
R-75 collapsed to its shipped one-liner. R-76 left open — this task does not fix
it, and nothing built here assumes an import/* directory stays 2775.
R-75 (spiked, GO) names the capability-map row it would flip: "File access via
browser" (00-capability-map.md line 96), currently IMPLEMENTED with the caveat
that browse/download through FileBrowser is exercised in no doc. Carries the
mandatory determinism constraint from P6 (sort + red-proof, or FileBrowser
force-recreates on every sync pass), the zero-removals invariant for
`documents`, the url.PathEscape-not-QueryEscape trap, and the four design forks
with evidence + recommendation, all awaiting operator ruling.
R-76 is minted for the two PRE-EXISTING defects the spike surfaced and
deliberately did not fix: FileBrowser Quantum creating 0644/0755 without
propagating setgid (breaking the shared-group chain one level below any
customer-created folder -- latent only because every userdata-touching app runs
uid 1000), and import/calibre living at 755 on demo-felhom where its same-app
sibling media/books is 2775.
Source: audits/SPIKE-catalog-data-paths-2026-07-26.md
Verdict GO, with one mandatory constraint.
Phase 0 HOLDS: the customer-facing path set is fully derivable from data the
controller already parses (ParseComposeClassifiableBinds), and
ValidateBackupSpec's refusal set already covers the path-safety class
data_paths: needs -- so the annotation-only design introduces no new
filesystem-write primitive. No catalog folder is reachable only via env
indirection; zero templates use long `type: bind` syntax.
Found off-brief: the derivation is ALREADY LIVE at deploy time
(stacks/manager.go:183 ensureUserdataMounts, "the deploy belt"), proven by a
clean two-box control -- media/podcasts exists on demo-felhom where
audiobookshelf is deployed, not on demo-hp, and is in no skeleton.
P0 GO: derived set 14 dirs vs hardcoded 14 (+media/podcasts, -documents); all
three hand-verified anchors match. P1 GO: 4 sources incl. nested + accented
names all index ready. P2 GO: deep-link template constructible in Go from
(sourceName, relPath) alone; login redirect preserves the target. P3 GO for the
feature, with a pre-existing setgid-chain break recorded. P4 GO: userdata/import
is shareable, guard unchanged, no live share created. P5: before-state inventory
captured read-only on both boxes. P6 GO-conditional: the naive derivation
produced 20 distinct outputs from 20 identical runs -- a guaranteed FileBrowser
force-recreate loop -- fixed by one sort. P7: 12 sources safe server-side.
No production code, no version bump, no live mutation.
- B2 demo-hp + B3 demo-felhom migrated to the island (agent 0.96.0), apps
served throughout (0 container restarts), island /storage 200, LAN DNS pinned
to the LAN IP, hub reports 0.96.0. No rollback.
- capability-map 'site/network change' row PARTIAL -> PROVEN-LIVE
- ROADMAP R-50 -> SHIPPED (fleet-migrated); add R-74 (island on Peti's cluster)
- nodes.md: both boxes island-bound, agent 0.96.0
- A4: scratch guest provisioned from golden on the island-configured drill came
up with net1 automatically (zero manual edits) — the v0.96.0 provisioning path
proven live. Spike method caveat CLOSED.
- REPORT: Phase A done + vouched 0.96.0; B0/B1/A4 done; B2/B3 await operator go.
Idempotent LAN->island migration procedure with rollback table + abort criteria
(firewall LAST). Validated verbatim on drill VM 300: rolled to r50pre, migrated,
island /storage 200, LAN DNS held on the LAN IP (Finding-1 pin), apps healthy,
hub reports 0.96.0. No rollback fired.
Provisioned nested-PVE drill 'drill-r50' (qm300 on demo-hp) via the v1.25.0
nested-vm ISO through the real day-0, then ran the R-50 empirical spike:
- vmbr9 portless island bridge + guest island NIC hot-add (LAN undisturbed)
- F1 replay money shot: LAN move survives on the island; LAN-literal bind
reproduces the 2026-07-20 daemon-exit bug verbatim
- dnsmasq trap confirmed live + lan_resolver.host_ip fix proven
- pin address-independent (leaf SHA-256 unchanged, HTTP 200 over island)
- survival matrix: agent/guest/host-cold-reboot all return on the island
Docs: SPIKE verdict BLOCKED->GO, ROADMAP R-50 SPIKED->GO, nodes.md drill VM,
REPORT overwrite.