diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f54fe..819951a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,84 @@ +## v0.206.0 — the box does not mint a key over a sealed package, and abandoning ends the question (2026-08-07, R-241) — MinAgent 0.127.0 + +**R-241 was ruled a MINTING defect, not a screen-predicate defect** (`SPIKE-r241-recovery-offer-2026-08-07.md`), +and that reversed the fix. The recovery screen was telling the truth: there genuinely was nothing +recoverable under the key the box held, **because the box minted that key itself, over the top of a +sealed package it already knew the hub was holding.** Mending the screen would have papered over a +machine quietly making its own backups unopenable. + +### 1. It stops minting + +`WriteOffboxSecrets` auto-generated on **one** input — does the file exist. Its two neighbours in the +same file, `OffsiteRecoveryOffer` and `needsOffsiteCredential`, both consult +`GetHubEscrowIdentityPresent()`. **The same fact was available on three paths and used on two.** + +Measured on the final walk: the credential self-heal reached it at 03:18:06Z and minted `9b4a9a9d…` +over a package sealing `30ef574f…`. The flag was not merely available at that moment — it was the +**precondition of the chain that reached the function**, logged at 02:48:03Z, six ticks earlier. + +The guard is a **conjunction** (a package held AND no key present), so a first-time box mints exactly +as before. The refusal is a **holding state, not a failure**: the transport is still written, so the +recovery screen can bring the tier up the instant the key arrives (R-219). Returning an error instead +would have left the hub re-staging a consumed credential for ever. New declared state +`offsite.state=awaiting_recovery_key`, shown inert to every existing hub reader from their code. + +### 2. The comparison it already made now drives the offer + +`EscrowAutoConfirmer.Reconcile` has compared the hub's `restic_pw_sha256` against the local key on +every ACK since SLICE 3. On the venue it logged the mismatch at **03:28:03Z — thirty-five minutes +before the customer looked** — and threw it away. It is now persisted, and `OffsiteRecoveryOffer` +gains **shape (c)**: the hub holds a package for a key other than the one we are using. + +**§7.2, decided deliberately:** a **known difference offers however old the reading** (age is not +gated on — gating would make a box offline from the hub silently stop offering); a **hash never +learned falls back to (a)/(b)**, because an empty hash is the hub positively saying its package seals +no key, not an unknown. + +### 3. Abandoning is now a finishable thing + +Setting the old history aside used to touch neither the escrow nor the key, so the hub went on holding +a package for a key nobody used and the question returned at every login. It now starts a **14-day +countdown**, visible and reversible, at the end of which the set-aside store **and the sealed package +that protects it are removed together** — after which shape (c) has nothing to compare and the offer +falls silent **because the state is right, not because something remembers it once was not**. + +The grace is real: the recovery offer stays reachable throughout. The two halves cannot be atomic +across two machines, so it is a two-phase commit whose confirmation rides the **same ACK** that +carries the request. Needs hub **v0.98.0**. + +### 4. The surface, and the trap that does not survive this session + +The full page appears **once per entry into the offered state, not once ever** (an epoch, so a box +rebuilt months later is a new situation). Three dismissal levers with three scopes — a per-visit +session cookie, a durable epoch-scoped reminder opt-out, and the existing "most nem" — and **none of +them removes the entry point on the backups page.** + +**§7.3 / Q7:** while a recovery is outstanding, „Helyreállítási kód létrehozása" is now **unavailable** +rather than merely captioned. Creating a new code seals the current key, demotes the package that +opens the earlier history to retained custody no shipped path can read (R-199), and re-enables the +screen while invalidating the code it accepts. A warning beside a button is a warning people click +past. + +**The abandon confirmation changed with the behaviour (§2.4):** it used to promise *„félretesszük — +nem töröljük"*, and after this the history **is** deleted, on a date it now states. + +### 5. Reminders and operator levers + +Escalating emphasis at 1/3/7/14 days for an undecided box, 5/3/1 days remaining for an abandoning one. +`--abandon-status` / `--abandon-extend=N` / `--abandon-stop` on the controller CLI, because the path +that actually happens is the customer telephoning. Both levers **refuse rather than no-op** when +nothing is running or the store is already gone. + +**The automatic 30-day abandonment is recorded and NOT built** → R-245. + +### Caught by tests rather than review + +Two real bugs in this change: `OffboxAwaitingRecoveryKey` omitted `t.Enabled`, so a customer who had +switched off-site off would have declared a holding state (caught by the existing +`TestOffsiteDeclare_DisabledTargetIsNotStranded`); and `recoveryInterrupts` returned early when the +offer was false, so the **falling** edge was never recorded and the page never came back — the exact +defect the epoch exists to fix, reintroduced inside the fix. + ## v0.205.0 — a backup that skipped an app the customer chose is not „Rendben" (2026-08-06, R-234) — MinAgent 0.127.0 **Two defects, and the one that actually produced the measured sequence was NOT the one filed.** diff --git a/CONTEXT.md b/CONTEXT.md index 9efc5a4..3b694b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,42 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-08-06 (v0.205.0 — R-234: a run that skipped a selected app is not successful) +Last updated: 2026-08-07 (v0.206.0 — R-241: the box does not mint a key over a sealed package) + +> **2026-08-07 — v0.206.0 (R-241). THE RULING, and it reversed the fix: this was a MINTING defect, +> not a screen-predicate defect.** The recovery screen was telling the truth — there genuinely was +> nothing recoverable under the key the box held, because **the box minted that key itself over the +> top of a sealed package it already knew the hub was holding**. Fixing the predicate would have +> papered over a machine quietly making its own backups unopenable. +> +> **THE RULE: a box does not create a repository key while the hub holds a sealed package for it.** +> The guard is a conjunction (package held AND no key), so a first-time box is untouched, and the +> refusal is a HOLDING state rather than a failure — the transport is still configured so the +> recovery screen can bring the tier up the moment the key arrives. +> +> **THE SECOND RULE: the fact that answers a question must be kept where the question is asked.** The +> hub-vs-local key comparison had been computed on every ACK since SLICE 3 and persisted nowhere; on +> the venue it logged the right answer thirty-five minutes before the customer looked at a screen +> that could not see it. It is now persisted and drives shape (c) of the offer. +> +> **THE THIRD RULE (the operator's, and it generalises): fix the state, do not remember that it is +> wrong.** Abandoning the old history now starts a 14-day countdown that removes the set-aside store +> and its sealed package TOGETHER, after which the offer falls silent on its own because there is +> nothing left to compare — rather than a "they decided" flag suppressing a screen over a state that +> is still wrong. The recovery offer stays reachable for the whole grace; a grace in which recovery +> is impossible is decorative. +> +> **Surface:** the full page appears once per ENTRY into the offered state, not once ever — a box +> rebuilt months later is a new situation. Three dismissal levers with three scopes, and none of them +> removes the entry point on the backups page. +> +> **Needs hub v0.98.0** for the superseded-package purge. `felhom-agent` untouched. +> +> **Two real bugs were caught by tests rather than by review** — a missing `t.Enabled` (an existing +> test) and a missing falling-edge sync that reintroduced the very defect the epoch exists to fix. +> +> **NOT built, deliberately:** the automatic 30-day abandonment (R-245, with the operator's reasoning +> recorded), and R-242's release-to-golden gate. > **2026-08-06 — v0.205.0 (R-234).** THE RULE: **a run that skipped an app the customer selected is > not a successful run.** The R-203 verdict block already said *"a warning beside a success is read diff --git a/REUSE.md b/REUSE.md index 11f7194..65ef023 100644 --- a/REUSE.md +++ b/REUSE.md @@ -57,6 +57,10 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| +| `backup.ErrOffboxSealedPackageHeld` + `IsOffboxSealedPackageHeld` + `sealedPackageHeld` + `OffboxAwaitingRecoveryKey` (R-241, v0.206.0) | controller/internal/backup/offbox.go | sentinel; `(error) bool`; `() bool`; `() bool` | **THE MINT GUARD** — a box never creates a repository key while the hub holds a sealed package for it | **The guard is a CONJUNCTION** (package held AND no key present). Widening it to "never mint" leaves a first-time box unable to start, waiting for a package that will never exist — pinned by `TestR241_ScenarioB_FirstTimeBoxStillMints`. **The refusal is a HOLDING state, not a failure:** `ApplyOffsiteTarget` catches the sentinel and still writes the transport, so `/recovery`'s synchronous tier-up (R-219) can bring the tier up the instant the key arrives; returning the error instead leaves `needsOffsiteCredential` true and the hub re-staging a consumed credential for ever. `OffboxAwaitingRecoveryKey` is **DERIVED, never stored** — and **`t.Enabled` is load-bearing in it**: a customer who switched off-site OFF is not awaiting anything (the Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the first draft omitted it and an existing test caught it). A nil settings store reads as "no package held" — a transient read failure must never become a permanently-held tier | +| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate | +| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone | +| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only | | `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback | | `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) | | `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) | diff --git a/controller/README.md b/controller/README.md index 8155f4a..56f63c5 100644 --- a/controller/README.md +++ b/controller/README.md @@ -167,6 +167,30 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated reads); a disconnected/decommissioned drive has its own signal. The manual „Távoli mentés most” also refuses SYNCHRONOUSLY when a run is already in flight, instead of answering „elindult” and leaving the previous run's verdict on the card. + **The box does not mint a repository key over a sealed package (v0.206.0, R-241):** + `WriteOffboxSecrets` auto-generated on ONE input — does the password file exist — while its two + neighbours in the same file both consulted `GetHubEscrowIdentityPresent()`. A rebuilt box's + credential self-heal therefore wrote a fresh key over the package the hub was holding for it, and + the recovery screen then correctly reported that nothing was recoverable under the key the box + held. The guard is a CONJUNCTION (a package held AND no key present), so a first-time box mints + exactly as before; the refusal is a HOLDING state that still writes the transport and declares + `offsite.state=awaiting_recovery_key`, so `/recovery` can bring the tier up the moment the key + arrives. `--abandon-status` / `--abandon-extend=N` / `--abandon-stop` are the operator levers. + **The recovery offer has a third shape (v0.206.0, R-241):** shape (c) — *the hub holds a package + for a key OTHER than the one we are using*. Shapes (a) "no key at all" and (b) "a run proved the + repo will not open" are proxies for that question and have each been wrong in opposite directions. + The comparison was already computed on every report ACK and persisted nowhere; it now lives in + `settings.HubEscrowKeySHA256`. A KNOWN difference offers however old the reading; a hash never + learned falls back to (a)/(b). + **Abandoning the old history is a finishable thing (v0.206.0, R-241):** the customer's confirmed + set-aside starts a **14-day countdown**, visible on Távoli mentés and reversible by recovering with + the code, at the end of which the set-aside store AND the hub's retained sealed package are removed + TOGETHER (two-phase commit; the hub half is `PurgeSupersededEscrowForCustomer`, hub v0.98.0). + Afterwards shape (c) has nothing to compare and the offer ends because the state is right. The + recovery screen appears **once per ENTRY into the offered state** (an epoch), the reminder bar is + per-visit, the durable opt-out silences the bar only, and **none of the three removes the entry + point on Távoli mentés**. While a recovery is outstanding, „Helyreállítási kód létrehozása" is + UNAVAILABLE — creating a new code would put the earlier history beyond every shipped path. (traefik/cloudflared/filebrowser) get curated Hungarian display identity from the `inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon); filebrowser is the only infra stack with a customer link (`files.`).