hub v0.112.0: a floor carries a declared MinAgent past the golden (R-472)
gates / gates (push) Successful in 18s

Operator ruling 2026-09-13. Above the vouched golden, a floor saved with a
declared MinAgent is served under the same agent comparison; an undeclared
one is still held beyond the golden. The declaration is stored beside each
floor as FLOOR=MINAGENT so it never carries to a later floor. Both floor
forms require min_agent above the golden (flash floor_needs_min_agent,
nothing stored). The Hosts page and the API log name the source.

Vouch path and R-120 gate untouched. Scenarios A-E tested; red-proofs A
and C in documentation/audits/rulings-r472-r475-2026-09-13/.
This commit is contained in:
2026-09-13 16:47:11 +02:00
parent 5ef0f52bcd
commit f181efd6a7
17 changed files with 599 additions and 27 deletions
+20 -2
View File
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
@@ -58,6 +59,10 @@ type Handler struct {
assetsMgr *assets.Manager
latestVersion LatestVersionProvider
// floorNotes (R-472): customer → the last served-floor decision logged, so the SERVED line is written
// once per change. Zero value is ready to use.
floorNotes sync.Map
// App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503.
mailSender mailrelay.Sender
mailLimiter *mailRateLimiter
@@ -592,7 +597,20 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
// on the dashboard, never silently stale.
if fd := h.store.ResolveManagedFloor(payload.CustomerID); fd.Floor != "" {
resp["min_controller_version"] = fd.Floor
// R-472: say where the served floor's agent requirement came from — once per change, not per
// report (a box reports every few minutes, and a line repeated that often is a line nobody reads).
note := fd.Floor + "|" + fd.MinAgentSource + "|" + fd.MinAgent
if prev, ok := h.floorNotes.Load(payload.CustomerID); !ok || prev.(string) != note {
h.floorNotes.Store(payload.CustomerID, note)
src := fd.MinAgentSource
if src == "" {
src = "none (uncoupled release)"
}
h.logger.Printf("[INFO] managed floor SERVED for %s: floor %s, agent requirement %q from %s (golden %s)",
payload.CustomerID, fd.Floor, fd.MinAgent, src, fd.GoldenVersion)
}
} else if fd.Held {
h.floorNotes.Delete(payload.CustomerID)
// ONE sentence, from the decision itself — the two hold reasons must never drift apart
// across this line and the dashboard (see ManagedFloorDecision.HoldReason).
h.logger.Printf("[INFO] managed floor HELD for %s: %s (controller floor withheld)",
@@ -2021,8 +2039,8 @@ var allowedEventTypes = map[string]bool{
"offsite_proof_empty": true,
// R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd.
"offsite_snapshots_dropped": true,
"crossdrive_completed": true,
"crossdrive_failed": true,
"crossdrive_completed": true,
"crossdrive_failed": true,
// controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's
// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
// discard the numbers (templates.go:129 priority)).