From 3738dfc548df6e592d87c219978a59a3e4a893b7 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 16 Sep 2026 16:56:01 +0200 Subject: [PATCH] hub v0.116.0: every new customer starts WITH the off-site copy (operator ruling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off-site is ON by default for a new customer — shared, 100 GB soft quota prefilled, the checkbox kept so an operator can opt a customer out. The reason is this repo's own [FACT]: the whole-guest tiers do not carry the data drive and a Tier-1 unit has no file leg, so with this unticked a one-drive box keeps NO copy of the household's own files. Measured on a fresh box the same day. The quota is prefilled because the fill warning only fires when quota_gb > 0. Also registers controller v0.244.0's app_deploy_started / app_deploy_failed in both allowedEventTypes and customerMessages, per the rule that the two move together. Red-proofed: dropping the default fails the new render test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CONTEXT.md | 21 +++++++ .../architecture/07-backup-architecture.md | 10 ++++ .../runbooks/VOLUNTEER-first-hour.md | 1 + documentation/runbooks/day0-install.md | 7 +++ hub/CHANGELOG.md | 19 ++++++ hub/internal/api/handler.go | 11 ++++ hub/internal/notify/templates.go | 5 ++ hub/internal/web/configs.go | 25 +++++++- hub/internal/web/offsite_default_test.go | 59 +++++++++++++++++++ 9 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 hub/internal/web/offsite_default_test.go diff --git a/CONTEXT.md b/CONTEXT.md index 121fa17c..ab2a5786 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,27 @@ > 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. +## Decisions 2026-09-16 (afternoon) — the backup promise: files protected from day one + +**Ruling: the off-site copy is ON for every customer from day one** — shared (a sub-account on the +pool box), **100 GB soft quota** prefilled, the checkbox kept so an operator can opt a customer out. +Hub **v0.116.0**, `handleConfigNewForm`. The quota is prefilled because `monitor/offsite.go` only +warns on a filling store when `quota_gb > 0`. Recorded in `07-backup-architecture.md` §6. + +**Ruling: the ep0 endpoint token gets `Datastore.Modify`**, narrowly — the hub's tenantsync auth-id +only, on `/datastore/felhom-offsite` only — so the R-511 adopt path stops being inert (R-534). + +**Ruling: the supervisor gets a second, SLOWER counter** — N restarts in 24 h raise a warning — because +the 3-in-15-minutes brake catches a fast crash loop and is blind to a slow one (measured: four +restarts 20 minutes apart, none accumulating, the only trace an `info` event that mails nobody, +R-531). **Not built in this task** — it is a row for the nightly. + +**What shipped with them (controller v0.244.0):** the app-backup page's contents label is now +**per tier** and a Tier-1 unit no longer claims „Adatok" for files it cannot hold (R-537); a unit +restore **refuses** rather than replaying a database over files that were never captured, and points +at the route that can return them (R-538); `app_deployed` moved from the deploy's ACCEPTANCE to its +completion, with `app_deploy_started` and `app_deploy_failed` as the honest pair (R-536). + ## 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 diff --git a/documentation/architecture/07-backup-architecture.md b/documentation/architecture/07-backup-architecture.md index b3fda807..329539e3 100644 --- a/documentation/architecture/07-backup-architecture.md +++ b/documentation/architecture/07-backup-architecture.md @@ -270,6 +270,16 @@ Recorded here so the encryption policy is not read as covering it. → **R-108** The tiers are **inputs to recovery**, not recovery routes. §7 and §8 say what they can actually do. +**[RULING 2026-09-16, operator] Tier 3 (off-site) is ON for every new customer — shared, 100 GB soft +quota.** Opting a customer out stays the per-customer exception, as DR-tier-by-default has been since +2026-07-12. **The reason is this document's own [FACT] two sections down:** the whole-guest tiers do +not carry `mp8 /mnt/felhom-drives`, and a Tier-1 unit has no file leg — so for the four class-A apps +a one-drive box with Tier 3 off keeps the household's files in NO tier at all. That was measured on a +fresh box the same day: five photos put into Nextcloud, deleted, restored from the box's own backup, +and none of them opened (`audits/DRILL-prove-fixes-0243-2026-09-16.md`, R-537/R-538). Implemented in +hub v0.116.0 (`handleConfigNewForm`). **What this ruling does NOT change: what any tier captures.** + + **The app update's safety precondition accepts ANY tier** (operator ruling 2026-09-13, controller v0.239.0, R-475): the first fresh copy in the order Tier 2, Tier 1, Tier 3; with none, it backs up first. Design: `09-update-architecture.md` §3 decision 8. **What a Tier-1 route back restores is only diff --git a/documentation/runbooks/VOLUNTEER-first-hour.md b/documentation/runbooks/VOLUNTEER-first-hour.md index f301668b..995dc5a8 100644 --- a/documentation/runbooks/VOLUNTEER-first-hour.md +++ b/documentation/runbooks/VOLUNTEER-first-hour.md @@ -22,6 +22,7 @@ > operator creates the tunnel (day-0 A.1) before sending this document. R-494 is narrowed to P3. > > Operator prerequisites this document silently depends on (§ „Az üzemeltető előtte"): +> - **a távoli másolat alapból be van kapcsolva** a new customer (shared, 100 GB) — leave it on unless the customer opts out; without it the household's own files are in no backup on a one-drive box. > create the customer; **hand over the Tulajdonosi jelmondat out of band**; **create the Cloudflare > tunnel and paste its token** (day-0 runbook A.1) — until R-494 is ruled. diff --git a/documentation/runbooks/day0-install.md b/documentation/runbooks/day0-install.md index a71f0f22..5d7f34cd 100644 --- a/documentation/runbooks/day0-install.md +++ b/documentation/runbooks/day0-install.md @@ -66,6 +66,13 @@ connected).* ### A.2 Create the customer in the hub +> **The off-site copy is ON by default (operator ruling 2026-09-16, hub v0.116.0).** The new-customer +> form arrives with „Enable offsite" ticked, type **shared**, soft quota **100 GB**. Leave it unless +> the customer is deliberately opting out: with it off, a one-drive box keeps NO copy of the +> household's own files — the whole-guest tiers do not carry the data drive and the local app backup +> holds no file leg (`architecture/07-backup-architecture.md` §6). + + Hub UI (`https://hub.felhom.eu`, operator password) → **Customers → New**: | Field | Value | Notes | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 8c8f6460..34371866 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,22 @@ +## v0.116.0 — every new customer starts WITH the off-site copy (2026-09-16, operator ruling; R-536 event pair) + +- **Off-site backup is ON by default for a new customer** — shared (a sub-account on the pool box), + soft quota **100 GB** prefilled. Opting a customer out stays a per-customer exception, exactly as + DR-tier-by-default has been since 2026-07-12. **Why it is a default:** the whole-guest tiers do not + carry the customer's data drive (07-backup-architecture §6, "[FACT] What the whole-guest tiers do + NOT carry") and a Tier-1 unit holds no file leg, so with this unticked a one-drive box keeps NO + copy of the household's own files. Measured on a fresh box 2026-09-16: five photos deleted, + restored from the box's own backup, and none of them opened. + The quota is prefilled rather than left empty because `monitor/offsite.go` only warns about a + filling store when `quota_gb > 0` — an empty field means no fill warning ever fires. + Red-proof: drop the default → `TestOffsite_NewCustomerFormDefaultsOn` fails at „the new-customer + form does not default the off-site copy ON". +- **`app_deploy_started` and `app_deploy_failed` registered** in `allowedEventTypes` AND + `customerMessages` (both, per the hub rule). They are controller v0.244.0's R-536 pair: the + acceptance that `app_deployed` used to assert, and the failure that used to be silence. Both are + customer-tier like `app_deployed` itself — the household that pressed „Telepítés" is the party who + wants to know it did not finish. + ## 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`), diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 3d7bd41e..6bd8340f 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2070,6 +2070,17 @@ var allowedEventTypes = map[string]bool{ "health_critical": true, "health_recovered": true, "app_deployed": true, + // R-536 (controller v0.244.0). `app_deployed` used to fire beside the 202 that merely ACCEPTED a + // deploy, so an install interrupted five seconds later stood on the timeline as a completed one + // — measured 2026-09-16 on the drill box with mealie, which ended `not_deployed`. The accept-time + // fact is worth keeping, so it becomes its own type, and the failure gets one too rather than + // being silence. `app_deployed` now means the compose up succeeded and the durable state was + // written. + // + // Both are customer-tier like `app_deployed` itself (NOT in operatorOnlyEvents): a household that + // pressed „Telepítés" is the party who wants to know it did not finish. + "app_deploy_started": true, + "app_deploy_failed": true, "app_removed": true, "app_start_failed": true, // controller fix-3 (CAMPAIGN-3): a deployed app is not running "disaster_recovery_started": true, diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index 7c34d974..aaff3040 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -139,6 +139,11 @@ var customerMessages = map[string]string{ "app_deployed": "Alkalmazás telepítve.", "app_removed": "Alkalmazás eltávolítva.", "app_start_failed": "Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort.", + // R-536 (controller v0.244.0): the pair that makes „telepítve" mean it. The started event is the + // acceptance `app_deployed` used to assert beside the 202; the failed one is what an interrupted + // install used to be — silence. A new type must enter allowedEventTypes AND this map together. + "app_deploy_started": "Alkalmazás telepítése elindult.", + "app_deploy_failed": "Egy alkalmazás telepítése nem fejeződött be.", // Disaster recovery events "disaster_recovery_started": "Katasztrófa helyreállítás elindítva.", diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index bf1b107e..a7bf3df8 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -651,7 +651,30 @@ func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.Customer // DR-tier-by-default is the new-customer default (operator decision 2026-07-12 #2); opting out // is the per-customer exception. func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { - s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, nil, "") + // Operator ruling 2026-09-16: EVERY new customer starts with the off-site copy switched on — + // shared (a sub-account on the pool box), 100 GB soft quota. Opting a customer out is the + // per-customer exception, exactly as DRTier has been since 2026-07-12. + // + // Why it is a default rather than a nicety: the whole-guest tiers do not carry the customer's + // data drive (07-backup-architecture §6, "[FACT] What the whole-guest tiers do NOT carry") and a + // Tier-1 unit holds no file leg, so on a one-drive box with this unticked the household's own + // files are in NO backup. That was measured on a fresh box on 2026-09-16: five photos deleted, + // restored from the box's own backup, and none of them opened. + // + // The quota is PREFILLED rather than left empty on purpose: `monitor/offsite.go` only warns about + // a filling store when quota_gb > 0, so an empty field means no fill warning ever fires. + // + // Every key the template touches is present, including box_type: the form compares it with `eq`, + // and a missing key renders as an untyped nil that makes `eq` fail the whole page. + overrides := map[string]interface{}{ + "offsite": map[string]interface{}{ + "enabled": true, + "type": "shared", + "quota_gb": 100, + "box_type": "", + }, + } + s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, overrides, "") } // handleConfigCreate processes the form submission to create a new config. diff --git a/hub/internal/web/offsite_default_test.go b/hub/internal/web/offsite_default_test.go new file mode 100644 index 00000000..9a116401 --- /dev/null +++ b/hub/internal/web/offsite_default_test.go @@ -0,0 +1,59 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +// Off-site-by-default (operator ruling 2026-09-16) — the same shape as DR-tier-by-default, and for a +// sharper reason: with the box's off-site copy unticked, a one-drive box keeps NO copy of the +// household's own files. The whole-guest tiers do not carry the data drive (07-backup-architecture +// §6) and a Tier-1 unit holds no file leg, so the files are covered by Tier 2 or Tier 3 and by +// nothing else. On 2026-09-16 that was measured, not argued: five photos deleted on a fresh box, +// restored from the box's own backup, and none of them opened. +// +// Red-proof partner: drop the overrides from handleConfigNewForm → this test fails at the checkbox. +func TestOffsite_NewCustomerFormDefaultsOn(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest("GET", "/configs/new", nil) + rr := httptest.NewRecorder() + s.handleConfigNewForm(rr, req) + + // The render itself is half the assertion: the form compares `box_type` with `eq`, and a default + // that supplies some keys but not that one makes `eq` fail and takes the whole page with it. + if rr.Code != 200 || rr.Body.Len() == 0 { + t.Fatalf("the new-customer form did not render: code=%d len=%d", rr.Code, rr.Body.Len()) + } + out := rr.Body.String() + if strings.Contains(out, "incompatible types") || strings.Contains(out, "executing \"config_form") { + t.Fatalf("the form rendered a template error:\n%s", out[max0(len(out)-400):]) + } + + i := strings.Index(out, `name="offsite_enabled"`) + if i < 0 { + t.Fatal("the new-customer form has no off-site checkbox at all") + } + if !strings.Contains(out[i:min0(i+200, len(out))], "checked") { + t.Fatal("the new-customer form does not default the off-site copy ON — a fresh one-drive box would keep no copy of the customer's files") + } + // The quota is prefilled on purpose: the fill warning in monitor/offsite.go only fires when + // quota_gb > 0, so an empty field means the operator is never told the store is filling up. + if !strings.Contains(out, `value="100"`) { + t.Fatal("the off-site soft quota must be prefilled (100 GB) — an empty quota silences the fill warning") + } +} + +func max0(n int) int { + if n < 0 { + return 0 + } + return n +} + +func min0(a, b int) int { + if a < b { + return a + } + return b +}