# VALIDATION — N100 rehearsal: RESET → generic ISO → self-bind → customer zero, 2026-07-18

> Supervised run of `runbooks/RUNBOOK-rehearsal-v3.md` (S1–S8). Viktor at the box as customer zero;
> CC collected the evidence afterwards (Phase A) before the N100 was unplugged for travel. **No
> production code changed by this run** — this document, the capability-map flips, and the ROADMAP
> items are its outputs. Every finding below is **recorded and ranked, never fixed here.**
>
> **Headline — read this first.** The complete final-product flow ran **end to end on real metal in
> one pass**: blank hardware → zero-touch install → self-registration → **customer self-bind** →
> credential delivery → day-0 → managed-floor lift → drive enrolment → escrow ceremony → offsite
> snapshots. The chain from **bind to a running current-version controller took 2 min 44 s**, and the
> managed floor lifted the controller **0.143.0 → 0.145.0 in 5 seconds, unattended**. Three things
> did not go to plan and are the run's real value: **(1)** the offsite repo came up **ORPHANED**,
> which S7 defines in advance as a finding; **(2)** the **PBS DR tier is silently non-functional** —
> descriptor applied, token rejected `401`, and the agent's own verify loop cannot read the
> credential to notice; **(3)** pushing the offsite config **restarted the controller and logged the
> customer out mid-flow**. Net: **core objectives green, 11 ranked items, one of them new and
> unforecast.**

---

## Run context

| | |
|---|---|
| Hardware | Intel **N100** (AlderLake-N), 16 GB; the same physical box as `VALIDATION-n100-baremetal-2026-07-16` |
| Host | `demo-felhom`, PVE **9.2.2** (kernel 7.0.2-6-pve), guest **9201** (`demo-felhom`) |
| Customer | `demo-felhom`, domain demo-felhom.eu — reborn clean-slate on this run |
| Versions | controller **0.145.0** · agent **0.90.0** · hub **v0.66.0** · ISO scripts **v1.20.0** · host-install **1.17.0** |
| Preparation | **RESET ×2** (host-delete-first): `demo-vm-felhom` 15:49:57, `demo-felhom` 16:08:51 — both with all five legs `ok` and escrow acked |
| Media | safety ISO first, then the real **universal pairing ISO v1.20.0**, `--loader mkimage`, Secure Boot **OFF** (mkimage is unsigned — the documented slice-B constraint) |
| Evidence | `180:~/n100-rehearsal/` (10 files + `MANIFEST.md`) |

> **⚠ Timezone.** The PVE host journal is **CEST (+0200)**; the guest, the controller and the hub are
> **UTC**. **Every timestamp in this document is UTC.** Subtract 2 h from any `journalctl` line
> before comparing it with a controller or hub timestamp. This split is live-captured in
> `timezones.txt` and is itself worth a ruling (see *Observations*).

---

## Timeline (wall-clocks, all UTC)

Sourced from the hub events stream, the hub DB, the controller log, the bootstrap journal and the
agent journal — **not** from expectation. Where the brief and the evidence disagreed, the evidence
is recorded and the disagreement is called out under *Discrepancies*.

| UTC | What happened | Evidence |
|---|---|---|
| 15:49:57 → 15:50:06 | **RESET #1** — customer `demo-vm-felhom`; legs `claim/db_purge/descriptor/hetzner/pbs` all `ok`, escrow acked | `hub-state.txt` `customer_resets` id=1 |
| 16:05:24 | `host_stale` + `node_stale` — "no report for 30m" | events |
| 16:08:51 → 16:08:59 | **RESET #2** — customer `demo-felhom`; all five legs `ok`, escrow acked (8 s) | `customer_resets` id=2 |
| **16:17:14** | **Blank metal first boot.** `felhom-bootstrap` in **PAIRING** mode; registers as an unclaimed appliance the same second | `bootstrap-journal.txt`; `appliance_registrations.first_seen` |
| 16:17:14 → 16:28 | 30 s pairing-poll loop; **every iteration exits 1** → **52** systemd `FAILED`/`Failed to start` lines on the customer-visible console | `bootstrap-journal.txt` |
| 16:28:55 | Operator mints + emails the **self-bind link**; 7-day TTL (expires 2026-07-25 16:28:55) | `selfbind_tokens` `created_at`/`emailed_at`/`expires_at` |
| **16:29:55** | **Customer self-binds** — token consumed, `attempts=0`, `locked=0`; `appliance_bound` source **`customer_selfbind`** | `selfbind_tokens.consumed_at`; events |
| 16:30:21 | `appliance_credential_delivered` (**26 s** after bind) | events; `appliance_registrations.delivered_at` |
| 16:30:40 | Hub `hosts` row created — `demo-felhom-8363b5` | `hosts.created_at` |
| 16:30:49 | Agent **0.90.0** daemon start; fast-tick armed | `agent-journal.txt` (18:30:49 CEST) |
| 16:30:51 | WG keypair generated → **registered with hub** → `10.77.0.2/32` → tunnel applied (**~2 s** end to end) | agent journal |
| 16:30:54 | Poke listener up, WG-confined `10.77.0.2:51822` | agent journal |
| 16:32:28 | `controller_started` **0.143.0** (the golden's version) | events |
| 16:32:29 | **Managed-floor update initiated — `initiated_by: auto-floor`** | `update-state.json` |
| 16:32:32 | **Day-0 provision SUCCESS** — done-flag written, unit disabled, bootstrap secrets scrubbed | `bootstrap-journal.txt` |
| **16:32:34** | **Floor update complete: 0.143.0 → 0.145.0 in 5 s**; new container created | `update-state.json`; `docker inspect .Created` |
| 16:32:39 | `controller_started` **0.145.0** | events |
| 16:33:24 | `node_recovered` — "Reports resumed (was stale for 27m)" | events |
| 16:41:41 | Drive init starts — `/mnt/hdd_1`, `/dev/sdb`, ext4 | controller log |
| 16:41:42 | Agent wipe gate: `storage_wipe` → `customer_confirmable`, **allowed=false**, `pending_confirmation` | agent journal |
| 16:41:51 | Wipe gate: **allowed=true**, `customer_confirmed` (9 s of customer thinking time) | agent journal |
| 16:42:19 | Device formatted ext4 | agent journal |
| 16:42:21 | Mount ensured → bound into guest → storage path registered → FileBrowser mounts synced. **Init 30.3 s** (F6 working) | controller log + agent journal |
| **16:50:22** | **RAM shrink 11675 → 8192 MB**, live cgroup, no reboot | controller log + agent `local-api: guest-memory resized` |
| 16:45:51 | Agent consumes a one-time **PBS** token secret (`secret_len=36`) and applies the descriptor | agent journal |
| 16:45:53 | `pbsdr: converged state=applied` | agent journal |
| **16:47:52** | **Hub mints a NEW PBS token secret — `consumed_at` is still NULL** | `host_pbs_secrets` |
| 16:47:55 → | Agent PBS verify loop: `cannot read token secret … permission denied` — **repeats every ~15 min, indefinitely** | agent journal |
| 16:54:58 | Config-refresh: hub `config_version=11 != applied=10` → **controller self-restarts** | controller log |
| 16:55:02 | Controller 0.145.0 restarts (container `StartedAt` 16:54:59Z — a **real restart**, not a reload) | controller log; `docker inspect` |
| 16:55:05 | `offsite-apply`: Storage Box target configured, *pending key escrow* | controller log |
| 16:56:29 | **Escrow ceremony started** via the wizard | controller log |
| 16:56:39 | Recovery code claimed (one-shot, not logged) | controller log |
| **16:56:41** | Hub-verified → `EscrowState` auto-confirmed → offsite runs enabled (**12 s** ceremony → confirmed) | controller log |
| 16:58:12 | First offsite run starts | controller log |
| **16:58:14** | **ORPHAN GUARD FIRES** — `offbox_repo_orphaned`, run skipped, card shown | controller log + events |
| 16:59:26 | Operator-confirmed reset: move-aside `/home/felhom-repo` → `.orphaned-20260718` | controller log |
| 16:59:32 | Fresh repo initialised; `offbox_repo_reset` | controller log + events |
| 16:59:38 → 16:59:50 | Backup run: 0 apps toggled, 0 snapshots, 7 s | controller log |
| **17:02:17** | **RAM grow 8192 → 12288 MB**, live cgroup, no reboot | controller log + agent journal |
| 17:05:15 | Calibre-Web deployed; deploy-page memory math now reads `total=12288MB` (**the grow rippled**) | events + controller log |
| 17:06:40 | Immich deployed | events |
| 17:11:16 | BookStack deployed (first start attempt failed `exit 1` 17:11:36; healthy by 17:12:03) | events + controller log |
| 17:13:38 → 17:14:22 | **Offsite run: 2 apps backed up, 1 skipped, 2 snapshots, 38 s** | controller log |
| 17:13:42 | Snapshot **`243ad08c`** — calibre-web | `restic-and-pool.txt` |
| 17:13:46 | Snapshot **`6df12205`** — immich | `restic-and-pool.txt` |

### Derived wall-clocks (the numbers R-3 needs)

| Leg | Wall-clock |
|---|---|
| Blank-metal boot → registered as unclaimed appliance | **< 1 s** (same second) |
| Self-bind link minted → customer consumed it | 1 min 0 s |
| **Bind → credential delivered** | **26 s** |
| Bind → agent enrolled + WG tunnel up | 56 s |
| **Bind → controller running the CURRENT version** | **2 min 44 s** |
| Credential delivered → day-0 provision SUCCESS | 2 min 11 s |
| **Managed-floor lift 0.143.0 → 0.145.0** | **5 s**, unattended |
| Drive enrol (confirm → usable, incl. mkfs) | 30.3 s |
| **Escrow ceremony → offsite runs enabled** | **12 s** |
| Boot → day-0 SUCCESS (incl. ~11.7 min waiting for the operator to send the link) | 15 min 18 s |

---

## Per-ledger verdicts

| Ledger | Verdict | Evidence |
|---|---|---|
| **S1** — stop apps, shut down, hub DOWN | **PASS** | `host_stale`/`node_stale` 16:05:24 |
| **S2** — the one BIOS visit (boot order, SB off, State-After-G3) | **PASS** (human leg) | Viktor at the box; SB-off is required by `--loader mkimage` |
| **S3** — the F1 moment: mkimage GRUB boots where stock GRUB threw `relocation 0x0` | **PASS** | The box reached first boot at 16:17:14 on the real board — **F1 is closed on physical hardware** |
| **S4** — unattended install → pairing mode → appears under Unclaimed appliances | **PASS** | `bootstrap-journal.txt` "PAIRING mode … registered — appliance token stored (0600)"; `appliance_registrations` id=3 `status=registered` |
| **S5** — the bind + the four-minute event composition, **no ~15-min stall between legs** | **PASS — and this is the immediacy proof** | Every post-bind leg is seconds apart: bind 16:29:55 → credential 16:30:21 → agent 16:30:49 → WG 16:30:51 → controller 16:32:28 → floor-lifted 16:32:39. **No leg stalled.** F5 auto-sizing landed the guest at **11675 MB** |
| **S6** — customer zero: claim → password → escrow ceremony → storage → **RAM grow *and* shrink** → deploy → enable offsite | **PASS on every leg, with two findings attached** | Ceremony 16:56:29→16:56:41; wipe gate pending→confirmed 16:41:42→16:41:51; **shrink 11675→8192 at 16:50:22 and grow 8192→12288 at 17:02:17, both live with no reboot**; 3 apps deployed. Findings: the config push logged the customer out (F3); offsite provisioning is **Save-triggered**, answering S6's open question |
| **S7** — first snapshot into a brand-new repo; **"if an orphan card appears, that's a finding"** | **FINDING — the orphan card appeared** | `offbox_repo_orphaned` 16:58:14. Recovered by the operator-confirmed move-aside 16:59:26→16:59:32; the run then produced **2 snapshots / 48.717 MiB** at 17:13:42–46. The *guard* worked exactly as designed — the finding is that it had to |
| **S7 (operator gauges)** | **PARTIAL** | Restic tab: Viktor's screenshots. **PBS DR tab was not captured, and the reason now matters — see F2** |
| **S8** — wrap: evidence, VALIDATION doc, map flips, ROADMAP, R-3 | **DONE** — this document | `180:~/n100-rehearsal/MANIFEST.md` |
| **Ledger 8** — immediacy legs, first live firing on a real onboarding | **PASS** | See S5. The "real-onboarding proof pending" note on the immediacy row can be cleared |
| **Ledger 9** — R-24 guest RAM resize, the *apply* (not just the refusals) | **PASS, both directions** | Shrink **and** grow, live cgroup, no reboot, and the new total rippled into the deploy page's memory math at 17:05:15 |

---

## Findings (recorded and ranked — not fixed)

Ranked as proposed for the ROADMAP; IDs assigned in `backlog/ROADMAP.md`.

### F1 — Liveness presence lags reality by ~30 min (→ R-30, P2-HIGH)
The box was powered off at the start of the run, yet the hub carried it as healthy until the
staleness threshold expired at **16:05:24** ("no report for 30m"), and cleared only at **16:33:24**
("was stale for 27m"). The host-delete guard compounds this: RESET refuses while any host row
exists, so a stale-but-"online" host stalls a forced teardown. **Direction:** derive presence from
Dir-2 long-poll connectedness (~90 s grace), decoupled from notification hysteresis; agent/ep0
analog later.

### F2 — PBS DR tier is provisioned-but-dead, and cannot self-report it (→ R-39, P2-HIGH, **NEW — not in the brief**)
This was not forecast by the brief and is the run's most serious finding. Three facts:
1. The descriptor **was** auto-provisioned: the agent consumed a one-time token secret at 16:45:51
   and reached `converged state=applied` at 16:45:53. `storage.cfg` holds a complete `pbs:
   felhom-pbs` stanza (namespace `demo-felhom`, token `felhom@pbs!demo-felhom`, encryption key,
   fingerprint).
2. **The storage is non-functional.** `pvesm status` → `felhom-pbs: error fetching datastores - 401
   Unauthorized`, `inactive`. A direct probe with the stored credential returns **401 on every
   endpoint including `/version`** — so this is authentication failure, not ACL scope. The transport
   is healthy (WG handshake 9 s old, 27.9 ms RTT, `/version` reachable), so the token itself is
   rejected server-side.
3. **The hub minted a second secret at 16:47:52 — two minutes after the agent applied the first —
   and `consumed_at` is still NULL** ~55 min later. The agent will not pick it up: it has already
   converged to `state=applied`, and its 15-minute verify loop cannot even read the credential to
   detect the failure (`open /etc/pve/priv/storage/felhom-pbs.pw: permission denied` — the agent
   runs non-root, writes the file through a root sudo wrapper, then tries to read it directly).

Net: a mint/consume race leaves the box on a stale secret, a converged state machine prevents
re-application, and a permission bug blinds the only loop that could notice. **The DR tier would
have failed silently on the first real restore.** This also explains why the S7 PBS DR gauge was
worth capturing — it would have shown the fault.

### F3 — Config apply restarts the controller and drops customer sessions (→ R-35, P2)
The offsite config push bumped `config_version` 10→11 at 16:54:58; the controller self-restarted
(container `StartedAt` 16:54:59Z) and came back at 16:55:02. In-memory sessions died with it, so
customer zero was force-logged-out mid-flow. **Direction:** hot-apply the offbox target, or persist
sessions across restart.

### F4 — Offsite provisioning is synchronous, with no status affordance (→ R-31, P2-HIGH)
Save runs the Hetzner sync in-request; the request can hit the nginx 504 while succeeding
server-side, so the operator cannot distinguish failed from slow, and a retry races the first
attempt. **Direction:** reuse the awaiting-card/poll idiom. Interim mitigation belongs in R-3:
**click once, wait, verify.**

### F5 — RESET destroys custody but not the ciphertext; the pool accumulates unattributed bytes (→ R-32, P2-HIGH)
The RESET ledger records `"hetzner":"ok"` — the sub-account **was** destroyed. But a Hetzner
sub-account is an access-control object, not a data object: deleting it leaves its directory intact.
Re-enabling offsite recreated a sub-account over the **same** `/home`, which still held the previous
lifecycle's repo — under a key destroyed by that same RESET. Hence S7's unexpected orphan card.
Measured on the pool box tonight:

| Path | Size | Status |
|---|---|---|
| `/home/felhom-repo` | **49 M** | live, attributed (2 snapshots, 48.717 MiB) |
| `/home/felhom-repo.orphaned-20260717` | **1.4 G** | unattributed, key destroyed — from the 07-17 incident |
| `/home/felhom-repo.orphaned-20260718` | **3.0 M** | unattributed, key destroyed — from tonight |

**Ruling from the run:** because RESET destroys custody, leftover ciphertext is unrecoverable **by
design**, so RESET gains a main-account purge of the customer base dir (the operator ack already
covers it). The move-aside guard **stays** for reinstall-without-RESET, where custody survives and
the card's promise is true. The operator Restic tab should show per-customer directory bytes against
attributed snapshot bytes so dead data cannot hide.

### F6 — Bootstrap pairing-poll spams the customer-visible console (→ R-33, P2-HIGH)
Each 30 s poll is a separate `felhom-bootstrap.service` run that exits 1, so systemd prints
`Failed to start …` on the console the customer is watching. **52 FAILED lines in ~11 minutes**,
while nothing was actually wrong — the box was correctly waiting to be bound. **Direction:** poll
*inside* the script (scripts v1.21) and reword the first-boot noise.

### F7 — Design-v2 gates are unenforced; one has been red for 16 releases
**Already ranked as R-29** (committed `2eccda4`, 2026-07-18) — the brief asked for a new ID, but the
finding already has one, including the `docker_run_volume_path_gate` /
`internal/appexport/estimate.go` attribution. **No new ID minted.**

### F8 — Backup data lifecycle is unmanaged (→ R-34, P2)
Apps with snapshots but no active backup (disabled or uninstalled) are invisible and undeletable.
**Direction:** an "inactive backups" section on Távoli mentés — name / size / last snapshot /
restorable, with an explicit double-confirmed per-app delete via `restic forget --tag` + nightly
prune. **RULING: the offsite toggle NEVER offers deletion — policy and destruction stay decoupled.**

### F9 — Post-RESET re-enroll leaves offsite "enabled but unprovisioned", silently (→ R-36, P2)
The hub knows the state and says nothing. Provisioning is **Save-triggered** (`configs.go`
`applyOffsite`) — confirmed live tonight, which answers S6's open question — and the re-enroll
auto-re-issue correctly skips unprovisioned targets (`handler.go`). **Direction:** flash it on the
customer page. Interim: an R-3 step. **Related:** auto-mint the self-bind link on customer
create/RESET so the console banner's "e-mailben kapott link" is always already true.

### F10 — Console-passphrase bind (→ R-27c, P2)
Viktor's direction, with the session's security constraints: passphrase issued at customer creation;
spray-hardened global-lookup endpoint (per-appliance + per-IP caps, constant-time, single generic
failure, alerting); accent-free wordlist for console keymaps; the web capability-link path
**RETAINED**; claim-by-email **RETAINED** as the delivery-channel proof. The self-bind email gains
the public universal-ISO download link + two-line instructions (the DIY case). **Secret-bearing
per-customer ISOs are ruled OUT.**

### F11 — Post-RESET health card shows stale pre-RESET warnings (→ R-37, P3)
Should read „RESET óta nincs adat".

### F12 — Installer GRUB menu offers interactive installers (→ R-38, P3)
Single default „Felhom telepítés" entry; interactive installers **REMOVED** (safety: wrong-disk
manual installs); felhom background. Squashfs/theme rebranding explicitly deferred.

---

## Observations (not findings — no ID minted)

- **Host/guest timezone split.** The PVE host runs CEST while the guest, controller and hub run UTC.
  Nothing is broken, but every cross-tier correlation needs a manual 2 h shift, which is exactly the
  kind of friction that produces wrong incident timelines under pressure. Flagged for a ruling
  rather than ranked unilaterally.
- **The escrow awaiting-card window was 12 seconds.** Ceremony → hub-confirmed took 16:56:29 →
  16:56:41, so the „megerősítésre vár, legfeljebb 15 perc" card rendered only briefly. The card is
  correct and its ≤15-minute promise is honest, but capturing *both* states on screen is a matter of
  luck at this speed.
- **BookStack skipped its first offsite run** — `no recovery unit found on any connected drive`
  (17:13:42). This is the expected pre-first-nightly-dump state; the nightly job is scheduled
  2026-07-19 04:15 CEST. **Watch item, not a finding** — it becomes one only if it still skips
  afterwards.
- **BookStack's first start attempt failed** (`exit 1`, 17:11:36) and succeeded on retry by
  17:12:03. Self-corrected; noted for pattern-watching only.
- **`felhom-samba` was not running** on the reborn box — sharing was not exercised this run
  (consistent with *What was NOT exercised*).

---

## What was NOT exercised (explicit)

- **C6 — a customer performs a restore unassisted.** The single open script step in R-3, and still
  **MISSING as evidence**. Remains remote-doable on the reborn box from vacation (the dashboard is
  remote).
- **freemail.hu deliverability.** The gmail half is evidenced (claim email → Inbox under
  `p=quarantine`); freemail.hu is Viktor's open half.
- **Sharing smoke on the reborn box** — R-7/R-7b were proven on the pre-RESET box; `felhom-samba` was
  not deployed here.
- **R-19 WAN-outage drill.**
- **The PBS DR operator gauge** — not captured, and F2 means it would have shown a fault.
- **BookStack's offsite unit** — watch item, see *Observations*.
- **SMB positive auth with the real household password** — carried over from R-7b, unchanged.

---

## Discrepancies vs the Phase-A brief (live evidence wins)

1. **"expect ~11675 MB ballpark" — the final `pct config` reads 12288 MB.** Both are right at
   different times: F5 auto-sized the guest to **11675 MB**, then customer zero **shrank it to 8192**
   (16:50:22) and **grew it to 12288** (17:02:17). The brief described the F5 value; the box ended
   above it because the grow was the last resize. Not a fault — and it is what completes ledger 9.
2. **"2.6 GB pool vs ~0 attributed" — measured 1.4 G + 3.0 M unattributed against 49 M attributed,
   across TWO orphaned directories, not one.** The F5 ruling is unaffected; the numbers are.
3. **"felhom-pbs entry present" — present but `401 Unauthorized` / `inactive`.** The brief treated
   the PBS descriptor as done; it is provisioned but dead. See F2.
4. **"RESET ×2 … second + third live firings" — the ledger holds exactly two rows, both tonight**
   (`demo-vm-felhom` 15:49:57, `demo-felhom` 16:08:51), on **two different customers**. Any earlier
   firing is not in `customer_resets`. Described here as tonight's two firings rather than as
   ordinals.
5. **"keep the name R-29 if the number is free" — it is not free.** R-29 was committed earlier the
   same day (`2eccda4`) for the design-v2 gate rot, which is also exactly the brief's item 5. The
   liveness item therefore takes **R-30**, and no new ID was minted for the red-gate item.
6. **The brief's tip `a03e142` is two commits behind** `bbaa844`; Viktor's R-3 draft landed as
   `runbooks/RUNBOOK-onboarding-draft-v3.md`, not `DRAFT-R3-onboarding-runbook.md`.
7. **F2 has no counterpart in the brief at all** — it was found by probing the box rather than by
   following the finding list.

---

## Evidence bundle

`180:~/n100-rehearsal/` — see `MANIFEST.md` there for the per-file map. Secrets were read only to
run probes and are recorded as lengths and metadata, never as values.
