R-366 slice 2 (decision 168): one operator line when the box's whole-guest archives include another key's (edge-triggered)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-07 10:18:50 +02:00
parent e03b18ea21
commit 9c94a9c6c0
5 changed files with 169 additions and 1 deletions
@@ -199,6 +199,16 @@ success, and oldest-proven ordering, which is now the tie-break **between due ti
remains, but only as the **evaluation interval** (6 h by default, chosen from a measured cost: one
due-check is 18 ms on a local dir storage and 392 ms on the PBS tier over the WAN).
**[FACT, built 2026-10-07 — R-727 + R-366 slice 2, `09` §3 decisions 158 and 168] Archives written with another key.**
On an encrypted (PBS) tier the pick takes only archives written with THIS box's key (R-727, agent v0.138.0): after a
reinstall the namespace still holds the earlier install's archives, which this box cannot open. Until 2026-10-07 that
skip was one INFO log line per archive and nothing else. **Now the host report carries, per tier, the count and the date
range of such archives** (`foreign_key_archives.tiers`; the stanza is absent until a tier was evaluated since the agent started, `tiers: []`
when none), and the hub records **one `restore_test_foreign_key_archives` info line per CHANGE of that set** on the operator's
timeline — not per archive, not per report, never mailed, never the household's. It names the retained escrow count:
the hub keeps the old key's escrow since hub 0.141.0 (R-366 slice 1). Tests `TestR366_*` (agent `internal/backup`,
hub `internal/api`).
**The hub's half is not optional.** `restoreProvenStaleAfter` was a flat 7 days derived from the very
cadence this replaced, and a weekly tier proved weekly reaches a proof age of **exactly** one interval
just before its next proof — 168 h against a 168 h window. It sat ON the line, so any ordinary delay
+1
View File
@@ -11,6 +11,7 @@
`documentation/audits/day-2026-10-07/B/red-hub-pve-mutations.txt`.
## Unreleased (2026-10-07)
- hub (R-366 slice 2, `09` §3 decision 168): the host report's new `foreign_key_archives` (per tier: the count and date range of whole-guest archives the restore-test skipped as written with another key) becomes ONE `restore_test_foreign_key_archives` info line on the operator's timeline per change of the set — not per archive, not per report; an absent stanza keeps the state, `tiers: []` clears it. Never mailed. `TestR366_ForeignKeyArchivesOneEventPerChange` (red-proved twice, `documentation/audits/day-2026-10-07/E/`). `07` §6 updated.
- hub (R-32 option A, `09` §3 decision 167): RESET's off-site leg on the shared pool box purges the household's folder — the repository and every `<repo>.orphaned-*` copy, nothing else — through the sub-account's OWN login (`offsitekeys.Registrar.PurgeRepos`, the `DeleteSetAside` route) BEFORE it deletes the sub-account; a failed purge or no purge route keeps the sub-account and fails the leg (re-run resumes). It used to delete only the sub-account (a login), leaving the folder for the next lifecycle. The false "repo DATA dies with the sub-account" comment is corrected. Tests `TestDeprovision_R32_*`, `TestPurgeRepos_R32_*` (red-proved, `documentation/audits/day-2026-10-07/D/`). `07` §6 (RESET) updated.
- hub (R-105 option A, `09` §3 decision 169): the two never-built "slim DR record" fields are retired. `hosts.dr_record_json` is no longer scanned (it had no writer and no reader); the escrow PUT no longer stores a `directive` (only a by-hand selftest flag ever sent one, and every wizard escrow overwrote it with `{}`); the re-enroll and restore-directive routes still serve the two opaque blobs and a `directive` of `{}`. The columns stay in the schema, unread. `TestR105_IdentitySaveLeavesDirectiveColumnAlone`, `TestR105_RetiredColumnsHaveNoReader` (red-proved, `documentation/audits/day-2026-10-07/F/`). `05` §9/§11 and `06` §3.5 corrected.
+74 -1
View File
@@ -11,6 +11,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
@@ -688,7 +689,13 @@ type hostReportPayload struct {
Backups []hostBackup `json:"backups"` // slice 6
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
Cloudflared struct {
// ForeignKeyArchives (R-366 slice 2, agent >= the 2026-10-07 release): per tier, the whole-guest archives the
// restore-test skipped as written with another key. Absent = not evaluated / an older agent → the hub keeps its
// state; `tiers: []` = evaluated, none.
ForeignKeyArchives *struct {
Tiers []hostForeignKeyArchives `json:"tiers"`
} `json:"foreign_key_archives"`
Cloudflared struct {
Status string `json:"status"` // agent >= 0.141.0: running | not_running | unknown (older: active | inactive | …)
Detail string `json:"detail,omitempty"`
} `json:"cloudflared"`
@@ -703,6 +710,14 @@ type hostReportPayload struct {
} `json:"log_tail"`
}
// hostForeignKeyArchives mirrors the agent's hub.ForeignKeyArchives (R-366 slice 2).
type hostForeignKeyArchives struct {
Target string `json:"target"`
Count int `json:"count"`
Oldest string `json:"oldest"`
Newest string `json:"newest"`
}
// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
type drRecipeVersionOnly struct {
RecipeVersion int `json:"recipe_version"`
@@ -926,6 +941,9 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
}
}
if rep.ForeignKeyArchives != nil {
h.noteForeignKeyArchives(custID, hostID, rep.ForeignKeyArchives.Tiers)
}
for _, bk := range rep.Backups {
if !bk.Success {
h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q",
@@ -1316,6 +1334,61 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
// registered operator-only in notify.operatorOnlyEvents.
const eventRepoKeyChanged = "offsite_repo_key_changed"
// eventForeignKeyArchives (R-366 slice 2, `09` §3 decision 168) — the box's whole-guest copies include archives an
// earlier install wrote with another key, which this box cannot open. Hub-internal, severity info: ONE line on the
// operator's timeline per change of the set (not per archive, not per report); never mailed, never the household's.
const eventForeignKeyArchives = "restore_test_foreign_key_archives"
// noteForeignKeyArchives turns a CHANGE of the reported set into one operator event (the caller skips an absent
// stanza, which keeps the state); an empty set clears it without an event. Pinned by TestR366_ForeignKeyArchives*.
func (h *Handler) noteForeignKeyArchives(customerID, hostID string, set []hostForeignKeyArchives) {
parts := make([]string, 0, len(set))
for _, f := range set {
if f.Count > 0 {
parts = append(parts, fmt.Sprintf("%s:%d:%s:%s", f.Target, f.Count, f.Oldest, f.Newest))
}
}
sort.Strings(parts)
sig := strings.Join(parts, ";")
if sig == h.store.ForeignKeyArchiveSignature(hostID) {
return
}
if err := h.store.SetForeignKeyArchiveSignature(hostID, sig); err != nil {
h.logger.Printf("[WARN] host %s: storing the foreign-key archive state failed: %v", hostID, err)
return
}
if sig == "" {
h.logger.Printf("[INFO] host %s: no whole-guest archive written with another key any more (R-366)", hostID)
return
}
var lines []string
total := 0
for _, f := range set {
if f.Count > 0 {
total += f.Count
lines = append(lines, fmt.Sprintf("%d on %s (%s … %s)", f.Count, f.Target, dayOf(f.Oldest), dayOf(f.Newest)))
}
}
retained, _ := h.store.CountSupersededEscrow(hostID)
msg := fmt.Sprintf("Host %s: %d whole-guest backup archive(s) were written with ANOTHER key (an earlier install of this box) — %s. "+
"This box cannot open them, so its restore test skips them. The hub holds %d retained escrow blob(s) for this host; "+
"the server-side prune removes the old archives as new copies land (R-366).", hostID, total, strings.Join(lines, "; "), retained)
details, _ := json.Marshal(map[string]any{"host_id": hostID, "archives": set, "retained_escrow": retained})
if _, err := h.store.SaveEvent(customerID, eventForeignKeyArchives, "info", msg, string(details), "hub"); err != nil {
h.logger.Printf("[WARN] %s event save failed for %s: %v", eventForeignKeyArchives, hostID, err)
return
}
h.logger.Printf("[INFO] host %s: %d whole-guest archive(s) written with another key — operator event recorded (R-366)", hostID, total)
}
// dayOf returns the date part of an RFC3339 time ("" stays "").
func dayOf(ts string) string {
if len(ts) >= 10 {
return ts[:10]
}
return ts
}
// eventEscrowBlobServed (R-199) — a host retrieved its own sealed identity blob. Hub-internal,
// operator-only. See handleHostEscrowGet for why every retrieval is loud.
const eventEscrowBlobServed = "escrow_blob_served"
@@ -0,0 +1,73 @@
package api
import (
"net/http"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-366 slice 2 (`09` §3 decision 168): archives the box's restore-test skipped as written with ANOTHER key reach the
// operator as ONE line per change of the set — not per archive, not per report; never mailed (info), never the
// household's.
func reportWithForeign(foreign string) string {
body := validReportBody("h1")
return strings.TrimSuffix(body, "}") + `,"foreign_key_archives":{"tiers":` + foreign + `}}`
}
func foreignEvents(t *testing.T, st *store.Store) []store.Event {
t.Helper()
ev, err := st.GetEventsByType("c1", eventForeignKeyArchives, time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
return ev
}
// COMPANION RED-PROOF (observed): remove the noteForeignKeyArchives call from handleHostReport → this fails with
// "a new set of other-key archives must record exactly one operator event; got 0". Restored.
func TestR366_ForeignKeyArchivesOneEventPerChange(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
set := `[{"target":"felhom-pbs","count":2,"oldest":"2026-08-18T03:58:43Z","newest":"2026-08-19T03:58:43Z"}]`
for i := 0; i < 3; i++ { // the same set on three reports → one event
if rr := do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(set)); rr.Code != http.StatusOK {
t.Fatalf("report %d: %d %s", i, rr.Code, rr.Body.String())
}
}
ev := foreignEvents(t, st)
if len(ev) != 1 {
t.Fatalf("a new set of other-key archives must record exactly one operator event; got %d", len(ev))
}
if ev[0].Severity != "info" || !strings.Contains(ev[0].Message, "2 whole-guest backup archive(s)") ||
!strings.Contains(ev[0].Message, "felhom-pbs (2026-08-18 … 2026-08-19)") {
t.Fatalf("the line must name the count, the tier and the date range; got %s %q", ev[0].Severity, ev[0].Message)
}
// An older agent or a fresh restart (the stanza absent) keeps the state: no new event.
do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
if n := len(foreignEvents(t, st)); n != 1 {
t.Fatalf("an absent stanza must keep the state; got %d events", n)
}
// The set changes (a third archive) → one more line.
do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(`[{"target":"felhom-pbs","count":3,"oldest":"2026-08-18T03:58:43Z","newest":"2026-08-25T03:58:43Z"}]`))
if n := len(foreignEvents(t, st)); n != 2 {
t.Fatalf("a changed set must record one more line; got %d events", n)
}
// Evaluated, none left → the state clears silently; the same set coming back later is news again.
do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(`[]`))
if n := len(foreignEvents(t, st)); n != 2 {
t.Fatalf("an empty list clears without an event; got %d", n)
}
do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(set))
if n := len(foreignEvents(t, st)); n != 3 {
t.Fatalf("after a clear, a set is news again; got %d", n)
}
}
+11
View File
@@ -2286,6 +2286,17 @@ func (s *Store) setSetting(key, value string) error {
return err
}
// ForeignKeyArchiveSignature / SetForeignKeyArchiveSignature (R-366 slice 2) hold, per host, the last reported set of
// whole-guest archives written with another key, so the operator event fires once per CHANGE, not every report.
func (s *Store) ForeignKeyArchiveSignature(hostID string) string {
return s.getSetting("foreign_key_archives:" + hostID)
}
// SetForeignKeyArchiveSignature stores the host's current signature ("" = none).
func (s *Store) SetForeignKeyArchiveSignature(hostID, sig string) error {
return s.setSetting("foreign_key_archives:"+hostID, sig)
}
// GetArtifactManifest returns the operator-recorded current artifact set. All-empty when nothing
// has been published yet.
func (s *Store) GetArtifactManifest() ArtifactManifest {