CHANGELOG v0.206.0 with the ruling that reversed the fix, the three changes,
the SEC 7.2 staleness decision, Q7's closed trap, and the two bugs the tests
caught rather than review.
CONTEXT carries the three rules this session established, in the form the next
session needs them:
- a box does not create a repository key while the hub holds a sealed
package for it;
- the fact that answers a question must be kept where the question is asked;
- fix the state, do not remember that it is wrong.
REUSE gains four rows, each carrying the trap rather than just the signature:
the mint guard is a CONJUNCTION and t.Enabled is load-bearing in the derived
predicate; the discriminator ships INERT unless wired in main.go's confirmer
literal; the countdown removes BOTH halves or neither and must be driven by an
injected clock; and the epoch must be synced FIRST and unconditionally or the
falling edge is lost.
README documents the three customer-visible changes and the operator levers.
No version literal was edited: the controller version is ldflags-only.
This commit is contained in:
@@ -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.**
|
||||
|
||||
+36
-1
@@ -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
|
||||
|
||||
@@ -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!) |
|
||||
|
||||
@@ -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.<domain>`).
|
||||
|
||||
Reference in New Issue
Block a user