REPORT: installer v1.28.0 published and verified live; evidence, and the Part 1 logs I lost again
gates / gates (push) Successful in 13s

This commit is contained in:
2026-08-13 08:19:41 +02:00
parent 823cd2949b
commit 2d05b29b82
23 changed files with 12255 additions and 143 deletions
+144 -143
View File
@@ -1,169 +1,170 @@
# REPORT — The door, part one: a correct code stops being called wrong (2026-08-12 night)
# REPORT — The removal that works once, and a status page that says what it is asking for (2026-08-13)
**Three repos.** hub **v0.103.0** · agent **v0.129.0** · controller **v0.214.0** · golden **0.214.0**.
Register: **R-311 CLOSED**, R-307 CLOSED, **R-312 / R-313 / R-314 / R-315 opened**. Ceiling
R-310 → **R-315**.
**Shipped:** `installer-v1.28.0`, published and verified against the live URL.
**Register:** R-305 CLOSED (by R-316), **R-316 / R-317 / R-318 opened**; ceiling R-315 → **R-318**.
**Venue:** `drill-r50` only. Neither demo box reinstalled. `peti-felhom` not contacted. Nothing deleted.
---
## 1. The spike's answer, first and in plain language
## 1. Cycle 3, before and after
**Can a customer restore from a set-aside store with the machinery that already exists? NO — and
building it is new surface, not wiring.** Established read-only, at `file:line`, before a line was
written.
**Before — on the PUBLISHED v1.27.0, from `virgin`, exit 1:**
Every restore entry point resolves the repository from `settings.GetOffboxTarget()` and the password
from the single `offboxPwPath()` file: `offboxLatestSnapshot` (`offbox_restore.go:85-86`),
`offboxSnapshotSize` (`:139-140`), `RestoreOffboxScratch` (`:206+`). **A grep for a repo-path
parameter anywhere in the restore chain returns nothing.** The only seam that installs a recovered
password, `InjectOffboxPassword` (`offbox.go:667`), writes that same one file — i.e. **adoption**.
```
[ERROR] a resolver is already bound to :53 on this host:
udp UNCONN 0 0 0.0.0.0:53 … users:(("dnsmasq",pid=7076,fd=4)) …
[ERROR] a resolver is already bound to :53 on this host — Felhom needs the guest reachable by name on your LAN.
Stop or reconfigure that resolver, OR point your LAN DNS at the guest's address, then re-run.
(Felhom does NOT touch DNS services on a host it does not own — this is a refusal, not a change.)
THIS LOOKS LIKE OURS. A previous Felhom install leaves the dnsmasq PACKAGE installed and its unit
enabled (only our config snippet is removed), and unconstrained it binds 0.0.0.0:53 — which is what
this gate is seeing. If this host had no dnsmasq before Felhom, clear it with:
systemctl disable --now dnsmasq
Then re-run this installer. If dnsmasq is YOURS, leave it and use one of the two routes above.
[ERROR] PRE-FLIGHT FAIL (exit 1) — fix the finding above and re-run
```
What the drill did to read the set-aside store was `restic` **by hand**, with `-r <alt repo>` and an
overridden `RESTIC_PASSWORD_FILE`. **That distance is exactly what (b)-to-(c) costs.**
**After — v1.28.0, three fresh cycles from `virgin`, exit 0:**
**So the session halted at Part 3 by its own rule, shipped Part 2, and hands back options → R-312.**
Cost to find out: ~35 minutes, read-only.
```
:53 now: 0
CYCLE 3 rc=0
[INFO] host DNS (:53): free
[OK] pre-flight passed
[OK] PRE-FLIGHT PASS (mode=byo) — no state written, no install step executed
```
## 2. Part 0 — the countdown, cancelled on your ruling
The three cycles were run on bytes **sha256-identical** to what the live URL now serves
(`cca1dedd9b7c152c…`, compared three ways: live URL, tested file, repo main).
Through the product's own operator path (`--abandon-stop`, which refuses rather than silently
no-opping), with the container **stopped first** so the running controller could not overwrite
`settings.json` from memory. **Proved, not trusted to the exit code:**
- `abandon_started_at` and `abandon_at`**gone**. `AbandonStatus` returns `Active=false` when
`AbandonAt` is empty (`offbox_abandon.go:111-113`), so no countdown renders.
- `abandon_repo_path`**deliberately kept**, as the pointer to the preserved store.
- The set-aside store — **still there**: 36 snapshot objects, full `config/data/index/keys/locks/
snapshots` structure. Both repositories still on the endpoint. **Nothing deleted anywhere.**
**And the thing you should know about what was preserved (R-313):** it holds **36 snapshots and one
key slot**, and it does **not** open with the box's current password (`Fatal: wrong password or no key
found`, exit 1 — measured). Its key is the one hashed `48741892f0ef…` — retained row id 4,
`identity_blob` **NULL**, a pre-v0.93.0 row. **The material was dropped by the R-198 defect during its
two-month window, so no recovery code in existence opens that store.** Keeping it is still the right
call — deleting is irreversible and a decision, not an accumulation — but it is 36 unreadable
snapshots, and that is the concrete, still-present cost of R-198 sitting on the endpoint.
## 3. Part 2 — what shipped, and a correction to the premise
**The premise needed correcting first.** The task described the customer being told *"the recovery code
did not open the sealed bundle"*. That is the **agent's local-API** reply. The **customer-facing
screen already hedged** (R-222/R-226) — it named both causes, named the kept package and its date, and
said it could not tell them apart. That was **honest**; it could not tell them apart **because nothing
ever looked**. So what shipped is smaller and more precise than "stop the lie": **the hedge becomes an
answer.**
- **hub v0.103.0** — `GET /hosts/<id>/escrow/retained`, the **first production caller
`ListSupersededEscrow` has ever had**. Self-scoped identically, same recovery-mode gate, same audit
event written before the bytes leave, capped at 16. Rows with a NULL `identity_blob` are **withheld
and counted** (`unopenable_count`): they can never open what the caller is asking about, and serving
them would let the screen promise recovery on exactly the boxes the original defect hurt.
- **agent v0.129.0** — retained packages tried **only after** the current one refuses; `422` with
`superseded_at`; bounded at 6 attempts (~1 s of scrypt each); fail-safe in every direction.
- **controller v0.214.0** — class `RecoveryCodeOpensRetained`, gated on MinAgent **0.129.0** via a
**second, separate** trust flag (a box can sit between 0.126.0 and 0.129.0). The message says the
code is correct, names the date, says the package is kept, says the **current** backups are
unaffected, and **promises no restore** — it routes to support, which can do it.
**The trade you should see stated:** the hub still cannot read any of it — sealed bytes in, sealed
bytes out, no decrypt path, no recovery code ever held. What widens is **volume**: a host key that
could fetch one opaque package can now fetch N, bounded by self-scope, the recovery-mode gate and the
cap.
## 4. Red-proofs — and where the lie actually lives
Every mutation asserted to have applied before its run.
| Repo | Mutation | Outcome |
|---|---|---|
| hub | serve the CURRENT row instead of retained | FAILS (count 2→1) |
| hub | drop the unopenable guard | FAILS (count 1→2, unopenable 1→0) |
| hub | drop self-scope | FAILS (403→200) |
| hub | collapse the route suffix | FAILS (count 1→0) |
| **agent** | **remove the retained lookup** | **FAILS — the fail-closed wrong-code error returns. THE LIE COMES BACK.** |
| agent | + 6 more (nil fetcher, wrong code, fetch failure, bounded attempts, predates-field, success path) | all pinned |
| controller | delete the new case | FAILS — but the customer gets the **neutral** message, because R-224's safe default catches it |
| controller | make 422 unconditional | FAILS — an agent that never looked is read as having looked |
| controller | route 400 to the new class | FAILS — a mistype is congratulated |
**Answering the question directly:** the lie returns when the **agent's** retained lookup is removed,
not when the controller's case is. R-224's safe default is doing its job one layer up.
## 5. The claim guard, and a gate whose positive control failed
**The claim guard had a blind spot the size of the recovery screen** — it scanned templates only,
while every recovery message is a Go string in a handler. It now scans `recovery_handlers.go` too, and
**on its first run convicted a pre-existing unregistered claim**. 8 → 10 registered claims.
**The wire-contract gate: declared, and honestly weaker than it looks (R-315).** The hub response was
made a **named type** so the gate could resolve it; the wire is declared as a fourth ROOT and the tag
count rose **174 → 182**, so the fields are inspected. But a positive control — renaming the
agent-side `superseded_at` tag — **still passed**, because the check is repo-wide name-presence and
the string also occurs as a map key elsewhere. The gate documents this ("name-reachability is not
use"), so it is a known limit, not a regression — **but declaring this wire bought documentation, not
enforcement**, and saying otherwise would have been false.
## 6. Live state
## 2. The mechanism, at `file:line`
| | |
|---|---|
| controller | **0.214.0** on guest 9201, `Up … (healthy)` |
| agent | **0.129.0** on `felhom-pve`, unit active, journal clean |
| hub | **0.103.0** — see §7 |
| golden | **0.214.0** baked + published |
| ownership **recorded** | `felhom-host-install.sh:1718` — preflight, `dpkg-query -W -f='${Status}' dnsmasq \| grep -q "install ok installed"` |
| ownership **read** | `:1097` (`_state_get dnsmasq_preexisting`) at uninstall |
| state file **deleted** | `:1268`*after* the read. **The order was already correct.** |
| Felhom's dnsmasq snippets removed | `:1162`, in the loop just above the ownership decision |
### Live proof on hardware — the 422, end to end
**Why cycle 2 concludes "pre-existing": package presence alone.** The preflight asks dpkg one question
and nothing else — it does **not** consult the absence of a record, and it cannot, because the record
was deleted with the state file. v1.27.0 stopped the unit and left the package, so the answer stayed
`yes` and our own package became "the household's" one cycle later.
**What the first uninstall did and did not remove:** snippets — removed. Unit — stopped + disabled.
State file (and with it the record) — removed. **Package — left.** That last one is the whole defect.
## 3. What v1.28.0 changes
When the record says we installed it, the uninstall **removes the package** as well as stopping the
unit. Order unchanged: read the record → act → delete the state.
**Two packages are recorded, not one.** `dnsmasq` ships the systemd unit; **`dnsmasq-base` ships
`/usr/sbin/dnsmasq`** (`dpkg -S`, measured on the box). Separately installable, so each is recorded at
preflight and taken back only if we added it.
**Guard rails:** ownership **read, never inferred**; the dependency check is an **`apt-get -s purge`
simulation** that proceeds only if the removal set is a subset of ours, else stop+disable **naming the
blocking package**; never interactive; **never fatal** — a wedged apt is recorded and restated in the
closing NOTE; and the success is **re-queried** rather than read off apt's exit code.
## 4. Scenarios and red-proofs
| Scenario | Result |
|---|---|
| **A** three cycles, fixed | install 3 **PASSES**, `:53` free at every uninstall |
| **B** resolver pre-dates Felhom | **untouched**`install ok installed`, unit active, both packages recorded `yes` |
| **C** something depends on it | **not purged**; log named `household-dns-thing`; stop+disable; `:53` free; dependent survived |
| **D** no ownership record | **untouched**, reason logged, exact command named |
**Red-proofs — mutation asserted applied before each run:**
| Mutation | Outcome |
|---|---|
| remove the purge call | cycle 2 records `yes`; **cycle 3 refuses, exit 1, in those exact words** |
| remove the ownership check | **the household's resolver is PURGED** (`unknown ok not-installed`) |
| infer ownership when no record | **the guess is taken** — a field box loses its own DNS |
**One honest note on red-proof B.** Its first run appeared to pass *for the wrong reason*: apt failed
with `dpkg was interrupted` (a broken state my own earlier manual package juggling left), so the
resolver survived by accident rather than by the guard. I repaired dpkg and re-ran, and it then failed
as required. Worth stating twice over: the mutation looked like a passing guard, and **my own fix's
apt-failure path was incidentally observed doing exactly what it should** — logging, continuing, naming
the command.
## 5. The machines already in the field
**Measured, not reasoned.** A field box is one with the package installed and **no record** — scenario
D. Its uninstall leaves the resolver running with the reason logged, and its next install refuses with
the message quoted in §1.
**Is there an honest durable marker? No, and none can be invented.** The Felhom `/etc/dnsmasq.d/
felhom-*.conf` snippets are deleted by the uninstall's own loop *before* the ownership decision; the
state file carrying the record is deleted at `:1268`; nothing under `/etc/felhom*` survives.
`/var/log/dpkg.log` does record the install and is a **timestamp** — refused by the standing rule as a
heuristic dressed as a fact. → **R-318**
**The preflight message, judged as a customer would:** it states the finding, keeps its two routes and
its written promise not to touch DNS on a host we do not own, and adds the one thing that gets a person
moving — *"THIS LOOKS LIKE OURS"* plus one exact command. It hedges correctly (*looks like*). Its
weakness is that it asks them *"did this host have dnsmasq before Felhom?"* — precisely the question we
can no longer answer for them. **That is a mechanism, not a rule: the command is on the screen at the
moment it is needed.**
## 6. The defect I nearly shipped
The agent decides whether to install dnsmasq with `os.Stat("/usr/sbin/dnsmasq")`
(`felhom-agent/internal/lanresolver/lanresolver.go:105`) — but that path belongs to **`dnsmasq-base`**,
while the unit comes from **`dnsmasq`**. Purging only `dnsmasq` would leave the binary, so the next
install would skip the apt step and then fail to enable a unit that is gone — **a silent resolver where
today there is at least a visible refusal.** That is why ownership is recorded per package.
It remains reachable where `dnsmasq-base` pre-dated Felhom (we correctly keep it). **Pre-existing, not
introduced here**, filed as **R-317**, and the uninstall now says so out loud rather than leaving it to
be found from a resolver that never came up. Fixing it is a one-line agent change, deliberately **not**
made here to keep this session to one repo.
## 7. Publication
`installer-v1.28.0` tagged; **both** `--ref`s in `manifests/webpage.yaml` moved (sidecar 327, init 372);
ArgoCD synced to `823cd29`; live deployment carries the new ref.
```
OLD code (opens retained row 11) HTTP 422 opens_retained: True
superseded_at: 2026-08-12T15:18:55Z
retained_has_restic_pw: True
"the recovery code is correct, but it belongs to an
EARLIER sealed package (superseded …), not the one
currently held"
WRONG code (negative control) HTTP 400 "the recovery code did not open the sealed bundle"
live URL serves: SCRIPT_VERSION="1.28.0" (was 1.27.0)
_dnsmasq_purge_owned present in the served file: 3
```
The hub half measured directly too: `GET …/escrow/retained` → **200**, `count=2`,
**`unopenable_count=1`** — that one being retained row id 4, the pre-v0.93.0 row whose material R-198
destroyed. The withholding rule is doing exactly what it was written for, on real data.
**Pushing publishes nothing here**`/scripts/` follows the tag. The runbook still says otherwise;
**R-309 remains open and is not forgotten**, deliberately out of scope.
**What was NOT walked, and why.** The customer's rendered sentence was **not** produced end-to-end.
`recoveryUnlockHandler` redirects to `/backups/remote` when `!recoveryOffer()`, and `demo-felhom`
holds its own repository password again (restored yesterday), so it is correctly **not** in the
offered state. Walking it would mean removing that password to fake a rebuilt box — destabilising a
healthy machine to render a sentence whose logic is pinned by six handler tests and whose upstream 422
is proven live. I did not. **Method stated: endpoint-level for the agent and hub, handler-level for the
message.** What the customer DOES see on this box today is the orphan card, and it is honest:
*„Megnyitni innen egyelőre nem lehet, és ez nem a kódodon múlik."*
## 8. Teardown — four layers
### A correction I have to make about my own last report — R-308 was wrong
| Layer | State |
|---|---|
| **machine** | `drill-r50`: 0 guests, no `/var/lib/felhom-install`, dnsmasq `not-installed` at the end |
| **host** | `drill-r50` **reverted to snapshot `virgin`, powered off**; `qemu.pid` removed. DooPlex was drill host only |
| **hub** | `drill-r50-0a4f9a` **RETAINED** — pre-existing since 2026-07-25, re-used not duplicated. **No new host or customer row was created this session.** Nothing deleted |
| **off-site** | **Not contacted at all** — no restic, sftp or endpoint call was made. `demo-felhom`: `last_status=ok`, `escrow=escrowed`, **no abandon countdown** |
I reported that the stored controller password no longer opens `demo-felhom`. **It does.** I had
stripped only DOUBLE quotes from the `~/.config/credentials` value; the values are wrapped in
**SINGLE** quotes, so I was sending a literal `'` as part of the password. Unquoted correctly it is 13
characters and logs in first try — **HTTP 302 with a session cookie**.
Both demo boxes untouched and healthy: controller **0.214.0**, agent **0.129.0**, `Up … (healthy)`.
The same bug then made this session's first live R-311 test read as a **failure** (HTTP 400) for
twenty minutes, and I nearly filed the fix as broken. It is the **third** wrong "the credential is
stale" verdict this project has produced from that one trap. R-308 is **withdrawn**; the real lesson
is filed with it — never let a shell decide what a secret is.
## 9. Evidence, and a repeated miss
## 7. What was dropped, named plainly
Logs at `documentation/audits/evidence-r316-2026-08-13/`: the fixed cycles (`F1`, `F2`, `F3`), all four
scenarios (`B`, `C`, `D`), and every red-proof (`RPA*`, `RPB*`, `RPD*`).
- **Part 3 (the route) — HALTED at the spike, by the task's own rule.** → R-312.
- **§7's fixture walk was not re-run end-to-end.** Yesterday's drill already proved the byte-identical
restore from a set-aside store; today's change is upstream of it (which sentence is shown), the
dashboard is unreachable headlessly (R-308), and the restore route does not exist (R-312). What was
proved live is the 422 itself.
- Explicitly out of scope and still open, so it does not read as forgotten: **R-305** (the removal fix
helps a machine once — the tester's second reinstall still hits it), the hub emails naming the
retired secret, **R-309** (the runbook's publication claim), the CI runs that fail with no log, the
twenty unread facts, the nine grey claims, **R-303**.
**The Part 1 logs did not survive.** They were on the drill VM's disk and were destroyed by the revert
to `virgin` between Part 1 and Part 2 — **the same mistake as Tuesday, in the same place.** The §1
quotation is verbatim from the live run as read at the time, and `RPA1/RPA2/RPA3.log` are an
independent reproduction of the identical three-cycle failure, retained. Recorded rather than glossed;
the fix is procedural and I have now got it wrong twice.
## 8. Bypass, stated as required
## 10. Observations — noticed, not acted on
`git push --no-verify` was used **once**, on `felhom-agent`. The `release-complete` gate refuses a
CHANGELOG entry whose tag and package do not exist; `release-agent.sh` refuses a tree that is not
pushed. Circular by construction. The bypass was immediately followed by the real release
(`release-agent.sh 0.129.0`), and the gates were re-run afterwards: **green**.
- **`dnsmasq-base` is flagged "automatically installed and no longer required"** after `dnsmasq` goes.
We deliberately do not `autoremove` — that would be a blast radius nobody asked for.
- **`apt-get -s purge` exits 0 even when it prints `E: dpkg was interrupted`.** The simulation output is
the signal, never the exit code — which is why the guard parses `Remv`/`Purg` lines rather than
trusting `$?`. Another entry for the exit-codes-that-lie class.
- The drill VM's `pveam` index is stale on `virgin` and needs `pveam update` before listing templates —
already recorded yesterday, hit again today in passing.