From 926723749d040fce2ee1552d38e0b72c671c5364 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 16 Sep 2026 10:53:22 +0200 Subject: [PATCH] hub v0.115.0: host_* mails skip the quiet hour (ruling 2, R-529); ruling 1 recorded (CC may sign agent_update until the first paying customer) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CONTEXT.md | 10 ++++++++++ .../04-control-plane-authorization.md | 12 ++++++++++++ documentation/architecture/08-alarm-ladder.md | 6 +++++- hub/CHANGELOG.md | 9 +++++++++ hub/internal/notify/dispatcher.go | 10 ++++++++-- .../notify/node_liveness_cooldown_test.go | 19 ++++++++++++++++--- 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 70d8016e..121fa17c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,16 @@ > language, one screen, no identifiers in the prose. Same subjects, different readers; merging them > would make one of the two audiences stop reading. `STATUS.md` is also a **view of `OPEN-ITEMS.md`** > and holds nothing of its own; this file does hold its own content, namely the standing rulings below. +## Decision 2026-09-16 — CC may sign `agent_update` jobs until the first paying customer (operator ruling 1) + +The operational signing key (`felhom-op-1`) and the recovery key stay on DooPlex at +`/mnt/5_hdd/felhom.eu/felhom-op-{operational,rec-recovery}` (+ `felhom_op_ed25519`), **mode 0600, owner +`kisfenyo`** (R-533). Until the first PAYING customer exists — testers do not count — CC may sign +`agent_update` ops with them, per box, as it did for `demo-hp-bb76ea` on 2026-09-15 and +`demo-felhom-8363b5` on 2026-09-16. **Revisit on the first sale.** A fleet-wide rollout step is still +undesigned (R-530). Also this day: `host_stale` / `host_down` / `host_recovered` join the `node_*` +cooldown bypass (ruling 2, hub v0.115.0, `08-alarm-ladder.md` §6.2). + ## Decision 2026-09-15 — „box is down" mail skips the one-hour quiet rule (operator ruling, decision A) `node_stale`, `node_down`, `node_recovered` bypass the 1-hour operator cooldown, with a 5-minute diff --git a/documentation/architecture/04-control-plane-authorization.md b/documentation/architecture/04-control-plane-authorization.md index 9d32d297..c353da2b 100644 --- a/documentation/architecture/04-control-plane-authorization.md +++ b/documentation/architecture/04-control-plane-authorization.md @@ -101,6 +101,18 @@ instructions. just set sizing + a threshold policy**, addable later without a redesign (Phase 4 §8). Out of scope now. +### 3.1 Where the keys live, and who may sign, TODAY [operator ruling 1, 2026-09-16] + +The two-key model above is unchanged. What the ruling settles is custody and reach **for this phase +only**: both keys sit on DooPlex at `/mnt/5_hdd/felhom.eu/felhom-op-operational` and +`felhom-rec-recovery` (plus `felhom_op_ed25519`), **0600, owner `kisfenyo`** — they arrived 0664 on +2026-09-15 and were tightened the same day (R-533). **CC may sign `agent_update` ops with the +operational key until the first PAYING customer exists; testers do not count.** Every signature is +per box (the blob binds `host_id`), so one box moves at a time and a fenced box cannot be swept along. +Proven twice: `demo-hp-bb76ea` 2026-09-15, `demo-felhom-8363b5` 2026-09-16. **Revisit on the first +sale** — at that point the key belongs behind the operator (or a hardware key, §7), and a fleet +rollout step still has to be designed (R-530). + ## 4. Rotation & compromise recovery The agents pin the operator public keys. The danger: rotation must **not** flow as plain hub config, diff --git a/documentation/architecture/08-alarm-ladder.md b/documentation/architecture/08-alarm-ladder.md index 72467c02..32c6d36c 100644 --- a/documentation/architecture/08-alarm-ladder.md +++ b/documentation/architecture/08-alarm-ladder.md @@ -192,7 +192,11 @@ v0.114.0, pinned by `TestOperatorCooldown_NodeLivenessBypassesQuietHour`). **Thi above for those three types only**, and it is a ruling, not a defect fix. The reason is BIGNIGHT F9 (2026-09-14): the controller was dead for 33 minutes; its `node_stale` mail was suppressed because F8's `node_stale` had used the hour 39 minutes earlier, and the later `node_recovered` mail was suppressed -the same way. The `host_*` agent-plane siblings are **not** in the ruling and keep the hour. +the same way. **EXTENDED by operator ruling 2, 2026-09-16 (hub v0.115.0, R-529): `host_stale`, `host_down` and +`host_recovered` join the bypass**, with the same 5-minute dedupe. They are the same sentence about the +same box — the agent's dead-man's-switch rather than the controller's — and leaving them on the hour +would have kept exactly the F9 silence on the host plane. Everything else keeps the hour (pinned by +`TestOperatorCooldown_NodeLivenessBypassesQuietHour`, which also asserts an unrelated type still waits). | Family | Grain | Key carries | Why | |---|---|---|---| diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 568cf3e4..8c8f6460 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,12 @@ +## v0.115.0 — „the box is down" skips the quiet hour on the HOST plane too (2026-09-16, R-529, operator ruling 2) + +- **`host_stale`, `host_down`, `host_recovered` join the `node_*` cooldown bypass** (`nodeLivenessEvents`), + with the same 5-minute per-host dedupe. The 2026-09-15 ruling named only the controller-plane types; + R-529 was filed the same day for the agent-plane siblings, and the operator ruled them in on + 2026-09-16. Everything else keeps the 1-hour operator cooldown. Recorded in `08-alarm-ladder.md` §6.2. +- Red-proof: with the three host types taken back out, `TestOperatorCooldown_NodeLivenessBypassesQuietHour` + fails at "host_stale 39 min after the previous one was suppressed (sent=1)". + ## v0.114.0 — the connect e-mail goes by itself, „box is down" skips the quiet hour, a stuck PBS token is adopted (2026-09-15, R-509 / R-511 / R-523 / R-518 / R-514) - **R-509 (P1, operator decision A 2026-09-15) — the self-bind link goes out whenever a customer is diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 7e93e330..a3c15912 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -401,12 +401,18 @@ func cooldownStackSuffix(eventType, detailsJSON string) string { // suppressed because F8's `node_stale` had used the hour 39 minutes earlier; the `node_recovered` // mail was suppressed the same way. "The box is down" must not wait out a quiet hour. A 5-minute // dedupe stays, so a flapping link cannot mail every sweep. The key is unchanged (customer:type), and -// a customer has one box, so the dedupe is per host. host_* (agent-plane) siblings are NOT in the -// ruling and keep the hour. +// a customer has one box, so the dedupe is per host. EXTENDED 2026-09-16 (ruling 2, R-529) to the +// agent-plane siblings host_stale / host_down / host_recovered — same box, same sentence. var nodeLivenessEvents = map[string]bool{ "node_stale": true, "node_down": true, "node_recovered": true, + // OPERATOR RULING 2026-09-16 (ruling 2, hub v0.115.0): the HOST-plane siblings join it. They are + // the same sentence about the same box — the agent's dead-man's-switch rather than the + // controller's — and R-529 was filed precisely because the 2026-09-15 ruling named only node_*. + "host_stale": true, + "host_down": true, + "host_recovered": true, } const ( diff --git a/hub/internal/notify/node_liveness_cooldown_test.go b/hub/internal/notify/node_liveness_cooldown_test.go index 08abaf9e..c94bf722 100644 --- a/hub/internal/notify/node_liveness_cooldown_test.go +++ b/hub/internal/notify/node_liveness_cooldown_test.go @@ -24,6 +24,7 @@ func TestOperatorCooldown_NodeLivenessBypassesQuietHour(t *testing.T) { // The previous node_stale mail went 39 minutes ago. d.mu.Lock() + d.opCooldowns["c1:host_stale"] = time.Now().Add(-39 * time.Minute) d.opCooldowns["c1:node_stale"] = time.Now().Add(-39 * time.Minute) d.opCooldowns["c1:app_start_failed"] = time.Now().Add(-39 * time.Minute) d.mu.Unlock() @@ -37,9 +38,14 @@ func TestOperatorCooldown_NodeLivenessBypassesQuietHour(t *testing.T) { if count() != 1 { t.Fatalf("node_stale 1 min after a sent one was mailed again (sent=%d) — the 5-minute dedupe is gone", count()) } + // Ruling 2: the same for a HOST-plane mail, 39 minutes after the last one. + d.processOperator("c1", "host_stale", "warning", "host stale", "{}", "hub") + if count() != 2 { + t.Fatalf("host_stale 39 min after the previous one was suppressed (sent=%d) — ruling 2 (R-529)", count()) + } // The design is unchanged for every other type: still the hour. d.processOperator("c1", "app_start_failed", "warning", "app down", "{}", "hub") - if count() != 1 { + if count() != 2 { t.Fatalf("a non-liveness type lost its 1-hour cooldown (sent=%d)", count()) } for _, et := range []string{"node_down", "node_recovered"} { @@ -47,7 +53,14 @@ func TestOperatorCooldown_NodeLivenessBypassesQuietHour(t *testing.T) { t.Fatalf("%s is not in the ruling's bypass", et) } } - if operatorCooldownFor("host_stale") != operatorCooldown { - t.Fatal("host_stale is not in the 2026-09-15 ruling and must keep the hour") + // Ruling 2 (2026-09-16, R-529): the host-plane siblings joined the bypass. + for _, et := range []string{"host_stale", "host_down", "host_recovered"} { + if operatorCooldownFor(et) != nodeLivenessDedupeWindow { + t.Fatalf("%s must bypass the quiet hour too (operator ruling 2026-09-16)", et) + } + } + // A type in neither ruling keeps the hour — the design is narrowed, not removed. + if operatorCooldownFor("storage_disconnected") != operatorCooldown { + t.Fatal("an unrelated type lost its 1-hour cooldown") } }