Files
felhom-controller/REPORT.md
T
admin a71cc58327 docs(R-71a): live evidence — 0.162.0 deployed both boxes, settle-gate GO captured
demo-felhom 9201 + demo-hp 9201 (G1 break-glass, secret shredded) both clean +
healthy on 0.162.0 with the settle-gate GO line. Honest B' accounting recorded:
the floor is in-memory (not persisted) so both above-floor boxes logged
"awaiting floor knowledge" then GOed ~10s later once the report ACK landed —
the report-ACK latency the 90s sub-bound is sized to, not a regression. The
zero-wait-when-floor-known invariant stays unit-proven (test E).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Drmtm2RzoqbkJZCNSFNQ
2026-07-24 07:53:49 +02:00

93 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REPORT — felhom-controller v0.162.0 (R-71a: the apply-bridge settle-gate)
**Date:** 2026-07-24 · **Task:** R-71(a) — structural fix for the F10 day-0 update-vs-bridge race
(consume-then-persist not crash-safe). **Scope:** `felhom-controller` only, ordering-only. No agent
(0.93.0 stays), no hub. MinAgent unchanged.
## The defect (DIAG-f10)
A fresh box boots below the operator floor (ISO 0.153.0 < floor 0.156.0). The apply-bridge consumes
the hub's single-use offsite password at boot; ~35 s later the managed **auto-floor update** replaces
the container mid-install → the new 0.156.0 process finds no installed key → `consume`**404**
offsite tier dead until an operator Re-issue. Recurs on **every** onboarding whose ISO floor lags the
managed floor; demo-felhom escaped by timing alone. Layering: the v1.25.0 golden≥floor build gate
PREVENTS the trigger for fresh installs, R-71c (hub) HEALS a burn, **this (a) removes the systematic
trigger for every restart shape.**
## What shipped (ordering only — consume/install/persist internals + the 404-no-oracle contract + the Consumer UNTOUCHED; R-71(b) stays rejected-by-design)
1. **Seam** `offsiteapply.SettleProvider.SettleState() (version, floor string, updateRunning,
floorKnown bool)` + `SettleFunc` adapter (seams.go) — a thin adapter over the self-updater's own
knowledge (`GetFloor()`/`IsUpdateRunning()`) in main.go. No second floor-fetch path.
2. **`Bridge.AwaitSettle`** — polls every 10 s BEFORE the 3-minute Reconcile context is created (the
deferral never eats the reconcile budget). Release table:
- `updateRunning` → wait (the swap's restart supersedes us).
- `floorKnown && version < floor` → wait (auto-floor update imminent — do NOT burn the password);
log `deferring offsite apply: managed update to floor <F> pending (we are <V>)`.
- `floorKnown && at/above floor` → **GO on the first poll, zero sleep** (the B invariant).
- `!floorKnown` past the 90 s sub-bound → GO + WARN (a hub that can't serve a floor can't serve a
consume → no burn risk).
- overall 5-min bound → GO + WARN (`R-71c self-heal is the belt`).
`ReconcileWhenSettled` runs the gate, then Reconcile under a fresh 3-min context.
3. **main.go** — the bridge goroutine MOVED to after the self-updater is constructed (so the adapter
can read it). Wired ONLY when an updater exists; otherwise `Settle` is nil = reconcile immediately
(no updater → no floor-update to race).
4. **Bounds** are named constants with rationale comments. The floor is in-memory
(report-ACK-derived), **NOT persisted** — so on any restart it is unknown until the first report
ACK. Source-confirmed latency: the startup report fires ~5 s after boot and `SetFloor` runs
synchronously in its ACK handler → floor normally known in ~510 s (≤~45 s across 3×15 s report
retries); the 90 s sub-bound is headroom over that worst case.
## Tests — all green (`go test ./...` clean, `go vet ./...` clean, gofmt clean)
Injectable clock (no real sleeps), fake `SettleState`, recorded `Consumer`
(`internal/offsiteapply/settlegate_test.go`):
- **A** below-floor defers (0 consumes while below) then GOes at floor → exactly one consume.
- **B** update-running defers then GOes when it finishes.
- **C** floor-unknown GOes at the 90 s sub-bound + the WARN line.
- **D** perpetually-below GOes at the 5-min overall bound + the WARN line.
- **E** (B) at/above-floor GOes on **poll 1** with **zero** sleep consumed.
- nil-provider → immediate reconcile; cancelled gate → skips reconcile (no consume).
## Red-proofs — four, each OBSERVED failing then restored
| # | Break | Observed FAIL |
|---|-------|---------------|
| A | gate removed (early `return` in AwaitSettle) | `TestSettle_BelowFloorDefersThenGoes`: "expected the gate to defer while below floor (>=2 sleeps), got 0" — below-floor consumes immediately (the F10 burn) |
| B | `updateRunning` branch removed | `TestSettle_UpdateRunningDefersThenGoes`: mid-swap box GOes immediately (log shows GO, deferral line absent) |
| C | floor sub-bound branch removed | `TestSettle_FloorUnknownGoesAfterSubBound` + red-proof: "release near the sub-bound 1m30s, waited **5m0s**" — floor-unknown drags to the overall bound |
| D | overall bound check removed | `TestSettle_PerpetuallyBelowFloorGoesAtOverallBound`: **20 s test timeout** — perpetual below-floor loops forever in `AwaitSettle` |
## Ship / honesty
The deferral paths ship **unit-proven + red-proofed, NOT live-fired** — their precondition is now
structurally prevented by the v1.25.0 golden≥floor build gate, which is the point. **Layering: gate
prevents, (a) defers, (c) heals.** R-71 → SHIPPED with (a)+(c); (b) recorded rejected-by-design.
## Build + deploy + LIVE (2026-07-24)
Image `gitea.dooplex.hu/admin/felhom-controller:0.162.0` (sha `e6e1146f…`, 145M) built + pushed.
Deployed to **both** boxes, clean start, healthy:
- **demo-felhom (guest 9201, `ssh felhom-pve`)** — `Up (healthy)`:
```
05:50:44 [INFO] [offsite-apply] settle-gate: awaiting floor knowledge (first report ACK) before offsite apply
05:50:54 [INFO] [offsite-apply] settle-gate: GO — at/above floor 0.156.0 (we are 0.162.0), no managed update running
```
- **demo-hp (guest 9201, G1 break-glass; secret extracted file→file + shredded)** — `Up (healthy)`:
```
05:52:53 [INFO] [offsite-apply] settle-gate: awaiting floor knowledge (first report ACK) before offsite apply
05:53:03 [INFO] [offsite-apply] settle-gate: GO — at/above floor 0.156.0 (we are 0.162.0), no managed update running
```
**Honest accounting of the B live leg:** both above-floor boxes GOed correctly, but NOT literally on
the first poll — because the floor is **in-memory, not persisted**, it is unknown at the very first
poll (the report ACK hasn't landed) so the gate logged `awaiting floor knowledge`, then GOed on the
next poll (~10 s later) the instant the floor became known and confirmed at/above. This is exactly
the documented finding, not a regression: the gate correctly refused to consume while the floor was
unknown, and added ~10 s to a background reconcile (invisible to any user). The B zero-wait-when-
floor-known invariant is unit-proven (test E); the ~10 s live wait is the report-ACK latency the 90 s
sub-bound was sized against. The important live proof holds: **the gate did not burn the one-time
password before the managed-update picture was clear.**