hub v0.159.0 (R-1154, after security review): the enroll reuse path re-issues nothing, it warns the operator

The first draft (5121536f, unpushed) re-issued the claim code and the off-site login on a
reuse whose old box was stale or switched off. A security review flagged it: the caller
holds only the household's retrieval passphrase, and both re-issues are operator
session-only actions (05 section 17.5, 09 decision 252, R-1055/R-1059). Now the reuse
path classifies the old box and re-issues nothing: gone -> host_reinstall_over_old_host
(warning, mailed to the operator: delete the old host and re-run, or re-issue by hand);
live -> host_reenroll_while_live (info). The mint path after the operator's delete is
unchanged and still re-issues. The once-per-silence record and the claim engine comment
edit are reverted. 05 section 8, README and CHANGELOG updated; tests red-proved.
This commit is contained in:
2026-10-19 03:29:44 +02:00
parent 5121536f10
commit b505ce4ba0
9 changed files with 265 additions and 268 deletions
@@ -222,24 +222,23 @@ Today: `GET /config/{id}` with an `X-Retrieval-Password` (Hungarian passphrase)
The existing `configgen` deep-merge + Hungarian-passphrase machinery is the base; it grows the
agent-first + key-pinning + restore-mode steps.
**A reinstall over an existing host record re-issues when the old box is gone [DESIGN, 2026-10-19 — R-1154, brief
**A reinstall over an existing host record is made loud, never acted on [DESIGN, 2026-10-19 — R-1154, brief
2026-10-19; hub v0.159.0].** `POST /host-enroll` is the installer's step 4, run on every pass (a `--resume` too), with
the customer's passphrase; an existing host record is REUSED (same id and key — the reinstalled box takes it over).
The clean-slate re-issues (the claim code, the off-site login — both live in the CONTROLLER) used to run only when a
record was minted, so a reinstall that kept the old record skipped both in silence. The reuse path now runs them
when, and only when, the old box is gone: **no controller wait connection open now**, and either the household
switched the box off on purpose (`deliberate_off_at` inside its hold) or **both** the agent's host report and the
controller's report are older than `alerting.stale_threshold` (or absent) — the threshold and comparison
`hostStatus` uses, no second definition. **Once per silence:** not again when it already ran since the host's last
report (an installer `--resume` before the new agent's first report). **Why this rule and not "re-issue on every
reuse":** a live box re-enrolls legitimately (a `--resume` after its agent started, a repair re-run), and re-issuing
there rotates the household's claim code and resets its off-site login under a running box; not re-issuing costs a
reinstall two operator presses, which the skip record now names. The controller report counts because the codes
live there: a box whose agent is broken but whose controller reports is live. **Known residual:** a reinstall done
less than the threshold after the old box's last report is still treated as live — it is recorded
(`host_reenroll_not_reissued`, operator-only, info) instead of silent, and the operator presses the two buttons or
deletes the old host first. The re-issue writes `host_reenroll_reissued` (also the once-per-silence record); both
types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`.
the customer's retrieval passphrase; an existing host record is REUSED (same id and key — the reinstalled box takes
it over). The clean-slate re-issues (a new claim code mailed to the household, the off-site provider password reset)
run only on the MINT path, i.e. after the operator deleted the old host — **the operator's delete is their gate,
and stays so.** A reinstall that kept the old record used to skip them in silence. Now the reuse path classifies the
old box and re-issues NOTHING: **gone** (no controller wait connection open now, and either `deliberate_off_at`
inside its hold or BOTH the host report and the controller report older than `alerting.stale_threshold` or absent —
the `hostStatus` threshold and comparison) → the operator-only WARNING `host_reinstall_over_old_host`, mailed to the
operator: delete the old host (Hosts page) and run the installer again, or re-issue the claim code and the off-site
login from the customer page; **live** → the operator-only INFO `host_reenroll_while_live`, recorded, never mailed.
**Why not re-issue automatically** (a security review of the first draft, the same day): the passphrase is a
household-held secret typed into the installer, and both re-issues are operator SESSION-ONLY actions — not even an
operator token may run them (§17.5; `09` §3 decision 252, R-1055/R-1059: an off-site re-issue „stops its backup for a
while"). Re-issuing on the reuse path would give those powers to anyone holding the passphrase whenever the box is
switched off or silent. The cost of the loud shape is two operator acts per reinstall that kept its old record. Both
event types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`.
## 9. DR model
+23 -17
View File
@@ -1,4 +1,4 @@
## v0.159.0 — a refused whole-guest backup has its own alarm text (R-1150); a reinstall re-issues the claim code and the off-site login when the old host is stale or off (R-1154) (2026-10-19)
## v0.159.0 — a refused whole-guest backup has its own alarm text (R-1150); a reinstall over the old host record warns the operator instead of staying silent (R-1154) (2026-10-19)
- **`whole_guest_backup_refused`** (R-1150). The agent's R-973 cadence cap reports a CRITICAL capability
`localapi:backup-cadence-cap` as degraded for 24 h after it refused a whole-guest backup a guest asked for beyond its
@@ -19,22 +19,28 @@
`TestR1150_OtherCapStillRaisesCapabilityDegraded`, `TestR1150_BornRefusedAlertsOnce` (red-proved: the pre-fix
shape emits `agent_capability_degraded` instead), `TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer`
(red-proved: without the register lines the household is mailed).
- **A reinstall over the old host record re-issues** (R-1154; `05` §8 [DESIGN]). `handleHostEnroll` reused an
existing host record and returned at once, so `reissueOnReenroll` (claim code + off-site login) ran only on a mint —
a reinstall that kept the old record skipped both in silence (demo-hp 2026-08-21; Tester4's standing reinstalls).
The reuse path (same host id and key, unchanged) now re-issues when the old box is gone: no controller wait
connection open, and either `deliberate_off_at` inside its hold or BOTH the host report and the controller report
older than `alerting.stale_threshold` (or absent) — the `hostStatus` threshold, wired from main
(`SetStaleThreshold`; unwired = never re-issues). Once per silence (an installer `--resume` before the new agent
reports does not re-issue again): the record is the info event `host_reenroll_reissued`, written by the mint path
too. A live box re-enrolling gets nothing re-issued and the skip is recorded (`host_reenroll_not_reissued`), so a
reinstall within the threshold is visible instead of silent. Both types operator-only, hub-origin only.
`internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostIsReissued`, `TestR1154_DeliberatelyOffOldHostIsReissued`,
`TestR1154_LiveHostIsNotReissued` (host report / controller report / wait connection / connected-while-off),
`TestR1154_ResumeDoesNotReissueTwice`, `TestR1154_NeverReportedAndUnwiredEdges`, `TestR1154_EventsAreOperatorOnlyAndHubOrigin`,
`TestR1154_MainWiresTheStaleThresholdIntoTheAPI` — each red-proved (the pre-fix early return: „claim generation 1→1,
claim mails 0, off-site re-issues 0"; an unconditional re-issue: „claim generation 1→2 … off-site re-issues 1" on a
live box; no once-per-silence check: „claim generation 1→3, off-site re-issues 2").
- **A reinstall over the old host record is LOUD, never acted on** (R-1154; `05` §8 [DESIGN]). `handleHostEnroll`
reuses an existing host record (same host id and key, unchanged) and the clean-slate re-issues (claim code, off-site
provider password) run only on the mint path — after the operator deleted the old host — so a reinstall that kept
the old record skipped them in silence (demo-hp 2026-08-21; Tester4's standing reinstalls). The reuse path now
classifies the old box and re-issues NOTHING: gone (no controller wait connection open, and either
`deliberate_off_at` inside its hold or BOTH the host report and the controller report older than
`alerting.stale_threshold` or absent — the `hostStatus` threshold, wired from main via `SetStaleThreshold`) →
`host_reinstall_over_old_host` (warning, mailed to the operator) „A reinstall of <customer> enrolled while the old
host record <host> still exists (<why>); nothing was re-issued — delete the old host (Hosts page) and run the
installer again, or re-issue the claim code and the off-site login from the customer page."; live →
`host_reenroll_while_live` (info, recorded). Both operator-only, hub-origin only. **Why no automatic re-issue:** the
first draft (commit 5121536f, never pushed) re-issued on a stale or off old host; a security review flagged it —
the passphrase is household-held, and both re-issues are operator SESSION-ONLY (`05` §17.5, `09` §3 decision 252,
R-1055/R-1059), so it would have let anyone with the passphrase reset the off-site login and rotate the claim code
of a box on holiday. `internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing`,
`TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing`, `TestR1154_LiveHostIsRecordedNotWarned` (host report /
controller report / wait connection / connected-while-off), `TestR1154_NeverReportedUnwiredAndMintEdges` (the mint
after the operator's delete still re-issues), `TestR1154_EventsAreOperatorOnlyAndHubOrigin`,
`TestR1154_MainWiresTheStaleThresholdIntoTheAPI` — red-proved: the pre-fix early return („no
host_reinstall_over_old_host event — the reinstall is silent again"), the first draft's automatic re-issue („the
reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1"), an unconditional „gone" („a live
box raised the reinstall warning").
## v0.158.0 — agent-only vouch, the hub's own snapshot alarms, the hub's own access log (2026-10-17) — built that night; DEPLOYED 2026-10-18 on the operator's yes (12:16)
+5 -4
View File
@@ -152,9 +152,10 @@ Events are the primary monitoring mechanism. Each event has: customer_id, event_
- `whole_guest_backup_refused` (warning) / `whole_guest_backup_refusal_cleared` (info) — the same checker, a
SEPARATE state (R-1150, v0.159.0): the agent's `localapi:backup-cadence-cap` reports that it refused a whole-guest
backup a guest asked for beyond its schedule (R-973). Operator-only; hub-origin only (not POST-able by a box).
- `host_reenroll_reissued` / `host_reenroll_not_reissued` (info) — `POST /host-enroll` reused an existing host
record and re-issued the claim code and the off-site login (the old box is gone), or did not (the box is live)
(R-1154, v0.159.0). Operator-only; hub-origin only.
- `host_reinstall_over_old_host` (warning) / `host_reenroll_while_live` (info) — `POST /host-enroll` reused an
existing host record while the old box looks gone (the operator is mailed: delete the old host and re-run, or
re-issue by hand) or while it is live (recorded) (R-1154, v0.159.0). Nothing is re-issued on that path.
Operator-only; hub-origin only.
- `host_leaf_changed` — `HostLeafChecker` (`monitor/host_leaf.go`, every 60s): an agent's reported
local-API **leaf fingerprint** changed (an agent re-key) — proactive, fleet-wide, independent of any
controller's channel-health check. Trust-on-first-report baseline; empty fp = unknown (never alerts).
@@ -187,7 +188,7 @@ Config retrieval uses a separate per-customer retrieval password (not the API ke
|--------|------|-------------|
| `POST` | `/api/v1/host-enroll` | Mint-or-reuse a host credential, body `{customer_id}` (auth: `X-Retrieval-Password` header) → `{host_id, api_key}` |
The Day-0 first-boot handshake (`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`) enrolls a Proxmox host's agent **using the customer's retrieval passphrase only** — the operator-tier global key never enters the field deploy path. `host-enroll` mints the per-host credential on the first call and **reuses it byte-for-byte** on every later call (idempotent — re-running the host-bootstrap never orphans a running agent's key), backed by `Store.GetHostByCustomer`. Auth is checked **before** any mint, so a wrong passphrase never creates a host row. Behaviour: first → `201`, reuse → `200`, wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. **Reinstall (R-1154, v0.159.0):** on a reuse whose old box is gone — its host report AND its controller report both older than `alerting.stale_threshold` (or absent), or the household switched it off on purpose, and no controller wait connection open — the enroll also runs the clean-slate re-issues (claim code, off-site login), once per silence; a live box re-enrolling gets nothing re-issued (`internal/api/reenroll.go`, `05` §8).
The Day-0 first-boot handshake (`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`) enrolls a Proxmox host's agent **using the customer's retrieval passphrase only** — the operator-tier global key never enters the field deploy path. `host-enroll` mints the per-host credential on the first call and **reuses it byte-for-byte** on every later call (idempotent — re-running the host-bootstrap never orphans a running agent's key), backed by `Store.GetHostByCustomer`. Auth is checked **before** any mint, so a wrong passphrase never creates a host row. Behaviour: first → `201`, reuse → `200`, wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. **Reinstall (R-1154, v0.159.0):** a reuse never re-issues the claim code or the off-site login (operator session-only powers — the passphrase must not reach them; they run on a mint, after the operator deleted the old host). When the old box looks gone — its host report AND its controller report both older than `alerting.stale_threshold` (or absent), or switched off on purpose, and no controller wait connection open — the operator gets a warning mail saying to delete the old host and re-run, or re-issue by hand (`internal/api/reenroll.go`, `05` §8).
The global-key `POST /api/v1/admin/hosts` (operator/HQ pre-mint) remains as the escape hatch pending the enrollment-cutover lock-down; it and `GET /config/{id}` are unchanged by this endpoint.
+10 -12
View File
@@ -113,9 +113,9 @@ type Handler struct {
poker Poker
// staleThreshold (R-1154, v0.159.0) is the hub's report-liveness threshold (alerting.stale_threshold — the
// same value hostStatus and both staleness checkers read). The host-enroll REUSE path re-issues the
// clean-slate credentials only when the old box has been silent past it (reenroll.go). 0 = not wired: the
// reuse path then never re-issues (the pre-v0.159.0 behaviour — the fail-safe direction). main wires it.
// same value hostStatus and both staleness checkers read). The host-enroll REUSE path judges by it whether
// the old box is gone (a reinstall over the old record → the operator's warning mail) or live (reenroll.go).
// 0 = not wired: no verdict, a log line only. main wires it.
staleThreshold time.Duration
}
@@ -1218,8 +1218,9 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
// Mint-once-reuse: an existing host for this customer is returned as-is (idempotent) — the SAME host id and
// key, so re-running the installer never orphans a live agent's key and a reinstalled box takes over the
// record. R-1154 (v0.159.0): when the old box has gone silent (or was switched off on purpose), this
// enroll is a reinstall and the clean-slate re-issues run here too — reenroll.go decides.
// record. The clean-slate re-issues do NOT run here: they are operator session-only powers and the
// passphrase must not reach them (R-1154, reenroll.go). A reinstall over the old record is made LOUD — an
// operator-only warning mail saying to delete the old host and re-run, or re-issue by hand.
existing, err := h.store.GetHostByCustomer(req.CustomerID)
if err != nil {
h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err)
@@ -1233,7 +1234,7 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.reissueOnReuseIfReinstall(cc, existing, time.Now())
h.noteReenrollOverExistingHost(cc, existing, time.Now())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
@@ -1260,17 +1261,14 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID)
// F2 (v0.57.0) — clean-slate reinstall of an existing customer. This mint path fires exactly
// once per fresh host record (a host deleted before the reinstall), so it is one of the two
// single-shot re-enroll hooks — the other is the reuse path above for a silent old host (R-1154).
// For a CLAIMED customer the fresh box has no
// once per fresh host record (the operator deleted the stale host, so re-enroll mints), so it is
// the single-shot re-enroll hook — and the operator's delete is its gate (R-1154: the reuse path
// above never re-issues; it tells the operator to delete). For a CLAIMED customer the fresh box has no
// password; auto-issue a reset code so the customer isn't stranded at the claim page hunting for
// the manual "request a new code" button (delivery rides the report ACK). No-op for an unclaimed
// customer (first provision). Also re-issue offsite credentials to the fresh box (F3) — the
// one-time offsite password only ever reached the OLD controller, so the fresh one has no target.
h.reissueOnReenroll(cc)
// R-1154: the record that the re-issues ran for this host record — an installer --resume that
// enrolls again before the new agent's first report must not re-issue a second time.
h.recordReenrollReissued(cc.CustomerID, hostID, "a new host record")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
+124 -112
View File
@@ -5,18 +5,25 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists re-issues the claim code and
// the off-site login when the old box is gone (silent past the stale threshold on BOTH report streams, or switched
// off on purpose), and NEVER while the box is live. reenroll.go carries the rule; `05` §8 the design.
// R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists. The reuse path NEVER
// re-issues the claim code or the off-site login (operator session-only powers — `05` §17.5; the passphrase must
// not reach them) and makes the reinstall LOUD: an operator-only warning mailed to the operator. reenroll.go
// carries the rule; `05` §8 the design.
const r1154Host = "c1-abc123"
@@ -25,12 +32,13 @@ type r1154Fixture struct {
st *store.Store
db *sql.DB
mail *nullMailer
offsite int // offsite re-issuer calls
offsite int // off-site re-issuer calls
gen0 int // the claim generation before the enroll
}
// newR1154 builds a CLAIMED customer c1 with an existing host record that has reported, the hub threshold at 45 m
// (the production value), a claim engine and a counting off-site re-issuer.
// (the production value), a claim engine, a counting off-site re-issuer, and a dispatcher behind a MAIL-HOLD
// marker (so the operator mail path is observable in notification_log as "held" with no network).
func newR1154(t *testing.T) *r1154Fixture {
t.Helper()
h, st, path := newTestHandler(t)
@@ -54,6 +62,13 @@ func newR1154(t *testing.T) *r1154Fixture {
return nil
})
h.SetStaleThreshold(45 * time.Minute)
holdDir := t.TempDir()
if err := os.WriteFile(filepath.Join(holdDir, mailhold.FileName), nil, 0o600); err != nil {
t.Fatal(err)
}
d := notify.NewDispatcher(st, "test-key-not-used", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.SetMailHold(mailhold.New(holdDir, log.New(io.Discard, "", 0)))
h.SetDispatcher(d)
if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil {
t.Fatal(err)
}
@@ -66,7 +81,6 @@ func newR1154(t *testing.T) *r1154Fixture {
}
t.Cleanup(func() { db.Close() })
f.db = db
f.exec(t, `UPDATE hosts SET created_at = datetime('now', '-30 days') WHERE host_id = ?`, r1154Host)
f.mail.sends = 0
f.gen0 = f.generation(t)
return f
@@ -100,8 +114,8 @@ func (f *r1154Fixture) generation(t *testing.T) int {
return cs.Generation
}
// enroll runs the installer's call and checks the reuse contract that must hold on EVERY branch: 200, the same
// host id and key.
// enroll runs the installer's call and checks the reuse contract that holds on EVERY branch: 200, the same host id
// and key, one record — and NOTHING re-issued (no claim rotation, no claim mail, no off-site re-issue).
func (f *r1154Fixture) enroll(t *testing.T) {
t.Helper()
rr := doEnroll(f.h, "c1", "pass-phrase")
@@ -118,20 +132,9 @@ func (f *r1154Fixture) enroll(t *testing.T) {
if n := countHostsForCustomer(t, f.st, "c1"); n != 1 {
t.Fatalf("host rows = %d, want 1 (the record is reused, never duplicated)", n)
}
}
// reissued reports what the enroll did: claim generation moved, mails sent, off-site calls.
func (f *r1154Fixture) assertReissued(t *testing.T, want bool) {
t.Helper()
gen := f.generation(t)
if want {
if gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 {
t.Fatalf("want re-issued once: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite)
}
return
}
if gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
t.Fatalf("want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite)
if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
t.Fatalf("the reuse path re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d — a passphrase must not reach these",
f.gen0, gen, f.mail.sends, f.offsite)
}
}
@@ -144,37 +147,75 @@ func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time {
return at
}
// The old box silent on both streams past the threshold → the enroll re-issues both, keeps the record, records it.
// operatorMailed waits (the dispatch is asynchronous) for an operator-channel notification row of eventType.
func (f *r1154Fixture) operatorMailed(t *testing.T, eventType string) bool {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
logs, err := f.st.GetRecentNotifications("c1", 50)
if err != nil {
t.Fatal(err)
}
for _, l := range logs {
if l.EventType == eventType && l.Channel == "operator" {
return true
}
}
time.Sleep(20 * time.Millisecond)
}
return false
}
func (f *r1154Fixture) eventMessage(t *testing.T, et string) string {
t.Helper()
var msg, sev string
if err := f.db.QueryRow(`SELECT message, severity FROM events WHERE customer_id='c1' AND event_type=? ORDER BY id DESC LIMIT 1`, et).Scan(&msg, &sev); err != nil {
t.Fatalf("event %s: %v", et, err)
}
return sev + "|" + msg
}
// The old box silent on both streams past the threshold → nothing re-issued, the operator WARNED by mail with the
// instruction.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored (the existing host returned at
// once, reissueOnReuseIfReinstall not called) this fails with
// once, noteReenrollOverExistingHost not called) this fails with
//
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
// r1154_reenroll_test.go: no host_reinstall_over_old_host event — the reinstall is silent again
//
// and with the first draft's automatic re-issue (reissueOnReenroll called on this branch) it fails with
//
// r1154_reenroll_test.go: the reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1 — a passphrase must not reach these
//
// Restored.
func TestR1154_StaleOldHostIsReissued(t *testing.T) {
func TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing(t *testing.T) {
f := newR1154(t)
f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t)
f.assertReissued(t, true)
if f.eventAt(t, eventHostReenrollReissued).IsZero() {
t.Error("host_reenroll_reissued not recorded")
if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
t.Fatal("no host_reinstall_over_old_host event — the reinstall is silent again")
}
if !f.eventAt(t, "claim_reissued_reenroll").After(time.Time{}) {
t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded")
got := f.eventMessage(t, eventHostReinstallOverOldHost)
for _, want := range []string{"warning|", "A reinstall of c1", r1154Host, "nothing was re-issued", "delete the old host", "re-issue the claim code and the off-site login from the customer page"} {
if !strings.Contains(got, want) {
t.Errorf("warning text lacks %q: %s", want, got)
}
}
if !f.operatorMailed(t, eventHostReinstallOverOldHost) {
t.Error("the operator mail path was not reached (no operator notification row)")
}
}
// Switched off on purpose minutes ago (both report clocks still fresh) → re-issued: the box said it was going down.
// Switched off on purpose minutes ago (both report clocks still fresh) → the same warning: the box said it was
// going down. Deliberate-off only changes which message the operator reads — it unlocks nothing.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed from decideReenroll this fails
// with
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed this fails with
//
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
// r1154_reenroll_test.go: deliberately off: no host_reinstall_over_old_host event
//
// Restored.
func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) {
f := newR1154(t)
f.hostReportedAgo(t, 3*time.Minute)
f.controllerReportedAgo(t, 3*time.Minute)
@@ -183,20 +224,24 @@ func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
t.Fatal(err)
}
f.enroll(t)
f.assertReissued(t, true)
if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
t.Fatal("deliberately off: no host_reinstall_over_old_host event")
}
if !strings.Contains(f.eventMessage(t, eventHostReinstallOverOldHost), "switched the box off") {
t.Errorf("the warning does not say why: %s", f.eventMessage(t, eventHostReinstallOverOldHost))
}
}
// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) keeps every
// credential: nothing re-issued, and the skip is recorded so a too-early reinstall is visible. Three ways to be
// live, each alone: the host report, the controller report (the codes live in the controller), the wait connection.
// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) is recorded as info,
// NOT mailed, and nothing is re-issued. Three ways to be live, each alone, plus connected-while-off.
//
// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse
// path this fails with
// COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" this fails
// with
//
// r1154_reenroll_test.go: host report fresh: want NOT re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1
// r1154_reenroll_test.go: host report fresh: a live box raised the reinstall warning
//
// (and the same for the other two cases). Restored.
func TestR1154_LiveHostIsNotReissued(t *testing.T) {
// (and the same for the other three cases). Restored.
func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, f *r1154Fixture)
@@ -231,80 +276,33 @@ func TestR1154_LiveHostIsNotReissued(t *testing.T) {
f := newR1154(t)
c.setup(t, f)
f.enroll(t)
if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
t.Fatalf("%s: want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d",
c.name, f.gen0, gen, f.mail.sends, f.offsite)
if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
t.Fatalf("%s: a live box raised the reinstall warning", c.name)
}
if f.eventAt(t, eventHostReenrollNotReissued).IsZero() {
t.Errorf("%s: the live skip was not recorded (host_reenroll_not_reissued)", c.name)
if f.eventAt(t, eventHostReenrollWhileLive).IsZero() {
t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name)
}
if !f.eventAt(t, eventHostReenrollReissued).IsZero() {
t.Errorf("%s: a re-issue record was written for a live box", c.name)
if !strings.HasPrefix(f.eventMessage(t, eventHostReenrollWhileLive), "info|") {
t.Errorf("%s: the live record must be info (recorded, never mailed): %s", c.name, f.eventMessage(t, eventHostReenrollWhileLive))
}
})
}
}
// Once per silence: an installer --resume of the same reinstall enrolls again before the new agent's first report
// — the old box is still "silent", and the re-issues must not run twice (the claim engine's single-shot rule).
// Then the new agent reports, goes silent again past the threshold (the next reinstall): re-issued again.
// Edges the code shows. A host record that never reported (an operator /admin/hosts mint) with no controller
// report → the warning; not wired (no threshold) → no event at all, nothing re-issued; and the MINT path (the old
// host deleted by the operator) still re-issues exactly as before — the operator's delete is the gate.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the once-per-silence check removed from decideReenroll this fails
// with
//
// r1154_reenroll_test.go: the --resume re-issued again: claim generation 1→3, off-site re-issues 2
//
// Restored.
func TestR1154_ResumeDoesNotReissueTwice(t *testing.T) {
f := newR1154(t)
f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t)
f.enroll(t) // the --resume
if gen := f.generation(t); gen != f.gen0+1 || f.offsite != 1 {
t.Fatalf("the --resume re-issued again: claim generation %d→%d, off-site re-issues %d", f.gen0, gen, f.offsite)
}
// The record is backdated so the next report is strictly after it (second-granularity clocks).
f.exec(t, `UPDATE events SET created_at = datetime('now', '-3 hours') WHERE event_type = ?`, eventHostReenrollReissued)
f.exec(t, `UPDATE hosts SET last_report_at = datetime('now', '-2 hours', '-30 minutes') WHERE host_id = ?`, r1154Host)
f.enroll(t)
if gen := f.generation(t); gen != f.gen0+2 || f.offsite != 2 {
t.Fatalf("a later reinstall (the box reported after the last re-issue, then went silent) must re-issue: claim generation %d→%d, off-site re-issues %d",
f.gen0, gen, f.offsite)
}
}
// Edges the code shows. A host record that never reported: a provisional record (operator /admin/hosts, no
// re-issue ran for it) → re-issued, like the mint it stands in for; a record the enroll itself minted (the
// re-issues ran at mint) → not again on the --resume. Not wired (no threshold) → nothing re-issued, the
// pre-v0.159.0 behaviour.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored, „provisional never reported"
// fails with „want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0"; with the
// once-per-silence check removed, „minted by the enroll" fails with „off-site re-issues = 2, want 1"; with an
// unconditional re-issue, „threshold not wired" fails with „want NOT re-issued: claim generation 1→2, claim mails
// 1, off-site re-issues 1". Restored.
func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) {
t.Run("provisional never reported", func(t *testing.T) {
// COMPANION RED-PROOF (observed 2026-10-19): with the call removed from the reuse path „never reported" fails with
// „never reported: no host_reinstall_over_old_host event"; with an unconditional "gone", „threshold not wired"
// fails with „not wired: an event was written". Restored.
func TestR1154_NeverReportedUnwiredAndMintEdges(t *testing.T) {
t.Run("never reported", func(t *testing.T) {
f := newR1154(t)
f.exec(t, `UPDATE hosts SET last_report_at = NULL WHERE host_id = ?`, r1154Host)
f.enroll(t)
f.assertReissued(t, true)
})
t.Run("minted by the enroll, never reported", func(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
h.SetStaleThreshold(45 * time.Minute)
calls := 0
h.SetOffsiteReissuer(func(context.Context, string) error { calls++; return nil })
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
t.Fatalf("first enroll = %d", rr.Code)
}
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusOK {
t.Fatalf("resume enroll = %d", rr.Code)
}
if calls != 1 {
t.Fatalf("off-site re-issues = %d, want 1 (at the mint only, not again on the --resume)", calls)
if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
t.Fatal("never reported: no host_reinstall_over_old_host event")
}
})
t.Run("threshold not wired", func(t *testing.T) {
@@ -313,14 +311,28 @@ func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) {
f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t)
f.assertReissued(t, false)
if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() || !f.eventAt(t, eventHostReenrollWhileLive).IsZero() {
t.Fatal("not wired: an event was written")
}
})
t.Run("old host deleted: the mint path re-issues", func(t *testing.T) {
f := newR1154(t)
if err := f.st.DeleteHost(r1154Host, false); err != nil {
t.Fatalf("DeleteHost: %v", err)
}
if rr := doEnroll(f.h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
t.Fatalf("enroll after delete = %d, want 201 (mint)", rr.Code)
}
if gen := f.generation(t); gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 {
t.Fatalf("mint after the operator's delete must re-issue: claim generation %d→%d, claim mails %d, off-site re-issues %d",
f.gen0, gen, f.mail.sends, f.offsite)
}
})
}
// Both R-1154 event types: operator-only, and hub-origin only — a box able to POST host_reenroll_reissued could
// plant the once-per-silence record and block the re-issue of its own reinstall.
// Both R-1154 event types: operator-only, and hub-origin only (no box can POST them).
func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} {
for _, et := range []string{eventHostReinstallOverOldHost, eventHostReenrollWhileLive} {
if !notify.IsOperatorOnly(et) {
t.Errorf("%s is not operator-only", et)
}
+76 -95
View File
@@ -13,130 +13,111 @@ import (
//
// POST /host-enroll is called by the installer (felhom-host-install.sh step 4) on EVERY pass, a --resume
// included, with the customer's retrieval passphrase. An existing host record is reused (same host id and
// key — a reinstalled box takes over the record). Until v0.159.0 the reuse path returned at once, so the
// clean-slate re-issues (reissueOnReenroll: the claim code, the off-site login) ran ONLY when the old host
// had been deleted first, and a plain reinstall skipped both in silence: the household stuck at the claim
// page, the off-site tier stuck (proven on demo-hp 2026-08-21).
// key — a reinstalled box takes over the record). The clean-slate re-issues (reissueOnReenroll: a new claim
// code mailed to the household, the off-site provider password reset) run ONLY on the mint path, i.e. after
// the OPERATOR deleted the old host. Until v0.159.0 a reinstall that kept the old record skipped them IN
// SILENCE (demo-hp 2026-08-21; Tester4's standing reinstalls).
//
// THE RULE (`05` §8). The reuse path re-issues when the OLD BOX IS GONE, judged by the hub's own clocks:
// THE RULE: the reuse path NEVER re-issues anything; it makes the reinstall LOUD instead.
//
// 1. NEVER while the box's controller holds a wait connection right now (intent presence "connected").
// 2. The household switched the box off on purpose (deliberate_off_at inside its hold) — the report clocks
// are not consulted: the box said it was going down.
// 3. Otherwise BOTH the agent's host report AND the controller's report are older than staleThreshold (or
// absent). The claim code and the off-site login live in the CONTROLLER, so a box whose agent is
// silent but whose controller still reports is live for this purpose (an installer re-run to repair a
// broken agent must not cost the household its codes). Same threshold, same comparison as hostStatus:
// no second definition of "live".
// 4. Once per silence: not when the re-issues already ran for this host since its last report (or, for a
// never-reported host, since the record was created) — a --resume of the same reinstall enrolls again
// before the new agent's first report. The record is the hub-origin info event host_reenroll_reissued.
// - The old box looks gone (no controller wait connection open now, AND either the household switched it
// off on purpose — deliberate_off_at inside its hold — or BOTH the agent's host report and the
// controller's report are older than staleThreshold or absent; the hostStatus threshold and comparison):
// the operator-only WARNING host_reinstall_over_old_host, mailed to the operator, saying what to do.
// - The box is live (a --resume after its agent started, a repair re-run, or a reinstall done within the
// threshold of the old box's last report): the operator-only INFO host_reenroll_while_live, recorded on
// the event list, never mailed.
//
// A live box that enrolls again (a --resume after its agent started, a repair re-run) gets its credentials
// and NOTHING re-issued; that skip is recorded as the info event host_reenroll_not_reissued, so a reinstall
// done within the threshold of the old box's last report is visible on the event list instead of silent.
// Not wired (staleThreshold 0) or a failed lookup: nothing is re-issued — the pre-v0.159.0 behaviour.
// WHY NOT RE-ISSUE AUTOMATICALLY (security review of the first draft, 2026-10-19): the passphrase is a
// household-held secret typed into the installer. Re-issuing a household's off-site credential and
// (re)sending its claim code are operator SESSION-ONLY actions — not even an operator token may do them
// (`05` §17.5; `09` §3 decision 252, R-1055 / R-1059: a re-issue „stops its backup for a while"). An automatic
// re-issue here would hand those powers to anyone holding the passphrase whenever the box is off or silent
// (a household on holiday). The operator's delete stays the gate: delete the old host, run the installer
// again, and the mint path re-issues exactly as before.
//
// Both event types are hub-origin only (not in allowedEventTypes — a box must not be able to plant the
// once-per-silence record) and operator-only. Pinned by r1154_reenroll_test.go.
// Not wired (staleThreshold 0) or a failed lookup: no verdict, a WARN log line only. Both event types are
// hub-origin only (not in allowedEventTypes) and operator-only. Pinned by r1154_reenroll_test.go.
const (
eventHostReenrollReissued = "host_reenroll_reissued"
eventHostReenrollNotReissued = "host_reenroll_not_reissued"
eventHostReinstallOverOldHost = "host_reinstall_over_old_host"
eventHostReenrollWhileLive = "host_reenroll_while_live"
)
// reenrollDecision is the verdict on a reuse-path enroll.
type reenrollDecision struct {
reissue bool // run the clean-slate re-issues
live bool // skipped because the box is live (recorded as an event)
why string // operator English, for the log line and the event
}
// reenrollVerdict classifies a reuse-path enroll.
type reenrollVerdict int
func (h *Handler) decideReenroll(host *store.Host, now time.Time) reenrollDecision {
const (
reenrollUnknown reenrollVerdict = iota // not wired / lookup failed — log only
reenrollLive // the box is live
reenrollGone // the old box looks gone: a reinstall over the old record
)
func (h *Handler) classifyReenroll(host *store.Host, now time.Time) (reenrollVerdict, string) {
if h.staleThreshold <= 0 {
return reenrollDecision{why: "no stale threshold wired"}
return reenrollUnknown, "no stale threshold wired"
}
if h.intentHub != nil {
if p := h.intentHub.Presence(host.CustomerID, now); p.State == intent.PresenceConnected {
return reenrollDecision{live: true, why: "the box's controller is connected to the hub right now"}
return reenrollLive, "the box's controller is connected to the hub right now"
}
}
var why string
if store.DeliberateOffHeld(host.DeliberateOffAt, now) {
why = "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC")
} else {
hostAge := "never reported"
if host.LastReportAt != nil {
age := now.Sub(*host.LastReportAt)
if age <= h.staleThreshold {
return reenrollDecision{live: true, why: "the host reported " + roundAge(age) + " ago"}
}
hostAge = "last reported " + roundAge(age) + " ago"
}
found, ctrlAt, _, err := h.store.LatestReportOffsitePresence(host.CustomerID)
if err != nil {
h.logger.Printf("[WARN] host-enroll: controller report lookup failed for %s: %v — nothing re-issued", host.CustomerID, err)
return reenrollDecision{why: "the controller report lookup failed"}
}
ctrlAge := "the controller never reported"
if found {
age := now.Sub(ctrlAt)
if age <= h.staleThreshold {
return reenrollDecision{live: true, why: "the controller reported " + roundAge(age) + " ago"}
}
ctrlAge = "the controller last reported " + roundAge(age) + " ago"
}
why = "the host " + hostAge + ", " + ctrlAge + " (stale threshold " + h.staleThreshold.String() + ")"
return reenrollGone, "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC")
}
// Once per silence.
since := host.CreatedAt
if host.LastReportAt != nil && host.LastReportAt.After(since) {
since = *host.LastReportAt
hostAge := "never reported"
if host.LastReportAt != nil {
age := now.Sub(*host.LastReportAt)
if age <= h.staleThreshold {
return reenrollLive, "the host reported " + roundAge(age) + " ago"
}
hostAge = "last reported " + roundAge(age) + " ago"
}
last, err := h.store.LastEventAt(host.CustomerID, eventHostReenrollReissued)
found, ctrlAt, _, err := h.store.LatestReportOffsitePresence(host.CustomerID)
if err != nil {
h.logger.Printf("[WARN] host-enroll: re-issue record lookup failed for %s: %v — nothing re-issued", host.CustomerID, err)
return reenrollDecision{why: "the re-issue record lookup failed"}
return reenrollUnknown, "the controller report lookup failed: " + err.Error()
}
if !last.IsZero() && !last.Before(since) {
return reenrollDecision{why: "already re-issued at " + last.UTC().Format("2006-01-02 15:04:05 UTC") + " for this install"}
ctrlAge := "the controller never reported"
if found {
age := now.Sub(ctrlAt)
if age <= h.staleThreshold {
return reenrollLive, "the controller reported " + roundAge(age) + " ago"
}
ctrlAge = "the controller last reported " + roundAge(age) + " ago"
}
return reenrollDecision{reissue: true, why: why}
return reenrollGone, "the host " + hostAge + ", " + ctrlAge + " (stale threshold " + h.staleThreshold.String() + ")"
}
// reissueOnReuseIfReinstall runs the clean-slate re-issues on the reuse path when decideReenroll says the
// old box is gone, and records the outcome. Best-effort, like reissueOnReenroll: it never fails the enroll.
func (h *Handler) reissueOnReuseIfReinstall(cc *store.CustomerConfig, host *store.Host, now time.Time) {
d := h.decideReenroll(host, now)
switch {
case d.reissue:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — re-issuing the claim code and the off-site login: %s",
host.HostID, cc.CustomerID, d.why)
h.reissueOnReenroll(cc)
h.recordReenrollReissued(cc.CustomerID, host.HostID, d.why)
case d.live:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued, the box is live: %s",
host.HostID, cc.CustomerID, d.why)
msg := "Host " + host.HostID + " enrolled again while the box is live (" + d.why + ") — the claim code and " +
"the off-site login were NOT re-issued. If this box was reinstalled, send a new claim code and re-issue " +
"the off-site login from the customer page."
h.saveReenrollEvent(cc.CustomerID, eventHostReenrollNotReissued, msg, host.HostID, d.why)
// noteReenrollOverExistingHost records (and for a reinstall, mails the operator about) an enroll that reused
// the old host record. It re-issues NOTHING and never fails the enroll.
func (h *Handler) noteReenrollOverExistingHost(cc *store.CustomerConfig, host *store.Host, now time.Time) {
v, why := h.classifyReenroll(host, now)
switch v {
case reenrollGone:
h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) over the old host record — %s; nothing re-issued, the operator is told",
host.HostID, cc.CustomerID, why)
msg := "A reinstall of " + cc.CustomerID + " enrolled while the old host record " + host.HostID + " still exists (" +
why + "); nothing was re-issued — delete the old host (Hosts page) and run the installer again, or " +
"re-issue the claim code and the off-site login from the customer page."
h.saveReenrollEvent(cc.CustomerID, eventHostReinstallOverOldHost, "warning", msg, host.HostID, why)
case reenrollLive:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — the box is live: %s", host.HostID, cc.CustomerID, why)
msg := "Host " + host.HostID + " enrolled again while the box is live (" + why + "); nothing was re-issued. " +
"If this box was reinstalled, delete the old host and run the installer again, or re-issue from the customer page."
h.saveReenrollEvent(cc.CustomerID, eventHostReenrollWhileLive, "info", msg, host.HostID, why)
default:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued: %s", host.HostID, cc.CustomerID, d.why)
h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) — not classified: %s", host.HostID, cc.CustomerID, why)
}
}
// recordReenrollReissued stores the once-per-silence record (and the operator's line) after the re-issues ran.
func (h *Handler) recordReenrollReissued(customerID, hostID, why string) {
msg := "Host " + hostID + " enrolled — " + why + " — the clean-slate re-issues ran (the claim code if the " +
"customer is claimed, the off-site login if it is provisioned)"
h.saveReenrollEvent(customerID, eventHostReenrollReissued, msg, hostID, why)
}
func (h *Handler) saveReenrollEvent(customerID, eventType, msg, hostID, why string) {
func (h *Handler) saveReenrollEvent(customerID, eventType, severity, msg, hostID, why string) {
details, _ := json.Marshal(map[string]string{"host_id": hostID, "reason": why})
if _, err := h.store.SaveEvent(customerID, eventType, "info", msg, string(details), "hub"); err != nil {
if _, err := h.store.SaveEvent(customerID, eventType, severity, msg, string(details), "hub"); err != nil {
h.logger.Printf("[WARN] save %s for %s: %v", eventType, customerID, err)
return
}
if h.dispatcher != nil {
go h.dispatcher.ProcessEvent(customerID, eventType, severity, msg, string(details), "hub")
}
}
+2 -3
View File
@@ -191,9 +191,8 @@ func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
//
// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already
// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees
// single-shot: the host-enroll mint path (a fresh host record), and since hub v0.159.0 (R-1154) the
// reuse path once per silence of the old box (api/reenroll.go). Pinned by api/r1154_reenroll_test.go
// TestR1154_ResumeDoesNotReissueTwice. Returns (generation, reissued, error).
// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
// which fires exactly once per reinstall). Returns (generation, reissued, error).
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
cs, err := e.Store.GetClaim(cc.CustomerID)
if err != nil {
+6 -6
View File
@@ -890,12 +890,12 @@ var operatorOnlyEvents = map[string]bool{
// since v0.44.0, and only configuration (enabled_events) made that true — the R-97c shape. Now structural.
"agent_capability_degraded": true,
"agent_capability_recovered": true,
// R-1154 (v0.159.0): a host-enroll that reused the old host record — re-issued the clean-slate credentials, or
// did NOT because the box is live. Info (recorded, never mailed), host ids and report ages: the operator's
// record. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes — a box must not plant the
// once-per-silence record). Listed in the SAME commit that mints them.
"host_reenroll_reissued": true,
"host_reenroll_not_reissued": true,
// R-1154 (v0.159.0): a host-enroll that reused the old host record — a reinstall over it (warning: the operator
// deletes the old host or re-issues by hand) or a live box enrolling again (info). Host ids, report ages, and an
// instruction only the operator can carry out. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes).
// Listed in the SAME commit that mints them.
"host_reinstall_over_old_host": true,
"host_reenroll_while_live": true,
}
// IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the
@@ -15,7 +15,8 @@ import "testing"
//
// Restored.
func TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer(t *testing.T) {
for _, et := range []string{"whole_guest_backup_refused", "agent_capability_degraded"} {
// R-1154 (v0.159.0): the reinstall-over-the-old-host warning rides the same register and the same proof.
for _, et := range []string{"whole_guest_backup_refused", "agent_capability_degraded", "host_reinstall_over_old_host"} {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{et}, 6); err != nil {
t.Fatalf("SaveNotificationPrefs: %v", err)
@@ -35,7 +36,7 @@ func TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer(t *testing.
t.Errorf("%s: the operator was not mailed — the guard must be customer-only", et)
}
}
for _, et := range []string{"whole_guest_backup_refusal_cleared", "agent_capability_recovered"} {
for _, et := range []string{"whole_guest_backup_refusal_cleared", "agent_capability_recovered", "host_reenroll_while_live"} {
if !operatorOnlyEvents[et] {
t.Errorf("%s must be operator-only (the pair's other edge)", et)
}