Files
felhom.eu/documentation/audits/SPIKE-r165-mp1-merge-2026-08-02.md
T
admin 41dbecb264
gates / gates (push) Successful in 8s
docs: R-167 + R-158 CLOSED, R-165 SPIKED, R-174..R-177 filed
R-167/R-158 shipped and proven live (controller v0.191.x, hub v0.89.0):
two new capability-map rows PROVEN-LIVE with live citations, and
07-backup-architecture.md §7.5's closing claim "nothing warns when an app
crosses the line" is now false and rewritten (S-1: an architectural
contract changed in the same session). §7.5 also gains the caveat that its
size bound is ONE BOX'S, not the fleet's.

Part 3 SPIKE (audits/SPIKE-r165-mp1-merge-2026-08-02.md): M1-M5 measured,
NO layout touched. Three findings the merge session must not re-derive:
"the layout" is not one thing (200G/50G vs 50G/20G vs 16G/8G); mp1 is a
BULKHEAD and not only a ceiling, so after the merge an overflow reaches
/var/lib/docker; the golden fails closed on the split in four places.
D-a's condition (1) is currently SATISFIED — no external box is in the
hub's register, and both demo boxes are Tier 0 and reinstallable.
Recommendation given, choice NOT made — it ends at the operator's ruling.

CONTEXT.md S-11 (D-c's routing, and why R-158's own backup_failed proposal
was overruled) and S-12 (the monitoring landed BEFORE the merge).
STATUS.md gains the plain-language section and the merge decision, with two
older entries trimmed so the page did not grow.

New rows R-174 (closed same session), R-175, R-176, R-177; each ID grepped
free before minting.
2026-08-02 23:56:16 +02:00

16 KiB

SPIKE — measuring the mp1mp0 merge (R-165, decision D-a)

Date: 2026-08-02 · Author: Claude Code · Status: MEASUREMENT ONLY — no layout changed

Decision D-a is already taken (CONTEXT.md S-5): the dedicated backup partition is merged away rather than resized. This document measures how, never whether. It ends in a question for the operator and a STOP — the merge itself is next session's supervised work.

0. What was and was not touched

Nothing was created, resized, moved or deleted. No golden was rebuilt, no guest config edited, no partition altered. Every figure below is a read of live state or of committed source. ep0 and Peti's box were not contacted at all (runbooks/target-selection.md, decision D-d).

The monitoring D-a requires (R-167) shipped before this measurement, in controller v0.191.x and hub v0.89.0, and was proven on guest 9201. That ordering is the point: D-a's condition (2) says the monitoring lands with the merge and never after, and landing it first is strictly better.


M1 — what is actually there

pct config 9201, both demo hosts, read 2026-08-02.

demo-felhom (N100) demo-hp (t740) golden default
rootfs 32 G 32 G OS_SIZE_GB=32
mp0 /var/lib/docker 200 G, backup=1 50 G, backup=1 GOLDEN_DOCKER_GB=16
mp1 /mnt/sys_drive 50 G, backup=1 20 G, backup=1 GOLDEN_SYSDATA_GB=8
mp8 /mnt/felhom-drives bind bind
mp9 bootstrap bind, ro=1 bind, ro=1

Occupancy (df, in-guest):

filesystem demo-felhom demo-hp
/ 945 M / 32 G (4%) 942 M / 32 G (4%)
/var/lib/docker (mp0) 13 G / 197 G (7%) 5.4 G / 50 G (12%)
/mnt/sys_drive (mp1) 2.0 G / 50 G (5%) 92 M / 20 G (1%)
data drive 21 G / 916 G (3%) — hdd_1 6.7 G / 938 G (1%) — nvme-1tb

THE FIRST FINDING IS THAT "THE LAYOUT" IS NOT ONE THING, AND THE SPREAD IS WIDER THAN §7.5 RECORDS. architecture/07-backup-architecture.md §7.5 states the default appliance as mp0 50G / mp1 20G — that is demo-hp exactly, and it is not demo-felhom, which ships 200 G / 50 G, four and two and a half times larger. Both differ again from the golden's own 16 G / 8 G, because provision grows the volumes after restore. Any merge plan expressed as a fixed pair of numbers is already wrong for one of the two boxes that exist.

The corollary matters more than the numbers. §7.5's headline bound — "≈ 19 GB of app data for a file-only app, ≈ 10 GB for a DB-backed one" — is derived from mp1 = 20 G. On demo-felhom, where mp1 is 50 G, the real bound is ~49 GB / ~24 GB. The architecture doc states one bound as if it were the fleet's, and it is one box's. That is a documentation defect independent of the merge and is filed as its own row.


M2 — what lives on mp1 (it is not only backups)

du, in-guest, read 2026-08-02.

demo-felhom  /mnt/sys_drive/felhom-data          2.0 G
               ├── backups/primary               269 M   Tier-1 units of DRIVELESS apps (~30 apps)
               ├── backups/secondary             1.7 G   Tier-2 mirrors
               └── userdata/import                12 K   the canonical drop-zone (R-75)

demo-hp      /mnt/sys_drive/felhom-data           92 M
               ├── backups/primary                       uptime-kuma, paperless
               ├── backups/secondary                     paperless-ngx
               └── userdata/import                       paperless

Four distinct things would move, not one, confirming the task's warning that a plan accounting only for the units is wrong:

  1. backups/primary/<app> — the RETAINED Tier-1 recovery unit of every app with no data drive. Thirty apps on demo-felhom.
  2. backups/secondary/<app> — Tier-2 cross-drive mirrors, including the _shares pseudo-stack. On demo-felhom this is 1.7 G of the 2.0 G — the majority is Tier 2, not Tier 1.
  3. userdata/import — the canonical, app-INDEPENDENT drop zone (GetImportRoot, R-75). It lives on the system drive by contract, not by convenience.
  4. userdata/ more generally — the system-data userdata namespace for driveless apps.

Observed occupancy is far below capacity on both boxes (5% and 1%). The measured pressure today is zero; the constraint R-163 records is a ceiling problem, not a current fill problem. That is worth stating plainly because it bears on urgency, not on correctness.


M3 — which merge shapes exist, and what each breaks

Three interacting assertions exist today, and a merge touches all three. All three were read at source; none had been measured before.

(a) The golden build ASSERTS the split and ABORTS if it is absent. felhom-agent/configs/build-golden.sh:130:

findmnt -no SOURCE,FSTYPE /mnt/sys_drive | grep -q . || {
  echo "[golden] FATAL: /mnt/sys_drive is NOT a separate mount — the mp1 split did not take"; exit 1; }

There is a sibling assertion for mp0 at :126, and two more at :315-324 that abort if the vzdump log shows excluding volume mount point mp0 or mp1. So the golden build fails closed on the split in four places, not one.

(b) The whole-guest archive's scope is rootfs + mp0 + mp1, and only because both carry backup=1. build-golden.sh:69 records why: "backup=1 is MANDATORY: without it vzdump EXCLUDES the volume (extra mountpoints default backup=0)".

(c) mount_parity compares the ARCHIVE's mpN set against the RESTORED guest's (felhom-agent/internal/reconcile/restoretest.go:271-283, mountParity at :347). Per slot it requires the same mount path and a restored size not smaller than the archive's, and it checks the reverse direction too. A mismatch fails the restore-test outright.

shape (a) golden assertion (b) archive scope (c) mount_parity verdict
S1 — one volume, two directories. mp1 stops existing; /mnt/sys_drive becomes a directory on the mp0 filesystem BREAKS — :130 aborts the build. Must be deleted, and :319's vzdump guard for mp1 with it HOLDS — scope becomes rootfs + mp0, still complete, because the data moved onto mp0 HOLDS for new archives (no mp1 in archive ⇒ none required in restore). A pre-merge archive restored into a merged guest is a different question — see below the shape D-a describes
S2 — two mounts, one backing pool. mp0 and mp1 remain separate mpN slots on the same storage HOLDS — both are still separate mounts HOLDS unchanged HOLDS unchanged does NOT remove the ceiling — two filesystems still have two independent dfs. This is thin-provisioning, not a merge, and it converts a clean per-app refusal into a shared-pool exhaustion that neither volume can see coming
S3 — grow mp1, keep the split (the shape D-a REJECTED — measured here as the baseline) HOLDS HOLDS HOLDS zero structural risk, one --sysdata-grow value. It is "the same wall further away" — D-a's own words — and does not close R-163

The one genuinely unmeasured item in M3, stated as unmeasured: whether a pre-merge archive (carrying mp1) restores cleanly into the merged world. Reading mountParity says it should — the restore recreates mp1 from the archive, so archive and restored guest agree, and parity passes. But that was reasoned from source and not executed, and this project's own record is that four production designs specced against unvalidated mechanisms were all wrong. It is a one-command restore-test on a Tier-0 box and should be run before the merge, not after.


M4 — the bulkhead question (the important one)

Today mp1 is not only a ceiling; it is a BULKHEAD. An app whose recovery unit outgrows the space is refused per app, its last good unit is preserved byte-identical (R-158's measurement), and — critically — the overflow cannot reach /var/lib/docker, because it is a different filesystem. The container runtime keeps running.

After S1 the same overflow lands on the filesystem Docker itself runs on. A runaway recovery-unit capture would fill /var/lib/docker, and a full Docker data-root is not a degraded state, it is a stopped one.

This is the one place where "the merge is cheap" stops being true, and it is why the warnings shipped first rather than alongside.

Four candidate replacements. No choice is made here — this is the operator's ruling.

# replacement what it costs what it leaves open
B1 — a reserved block percentage on the merged filesystem. tune2fs -m reserving N% for root; the controller runs unprivileged, so a capture cannot consume the reserve while root-owned Docker can one tune2fs at build time; no code the reserve protects root, not Docker's runtime need specifically; sizing it is a guess without a measured worst case
B2 — a refusal threshold in the capture path. captureAllRecoveryUnits refuses when free space would drop below a floor, per app, and emits the alert R-158 just wired small, local, testable; reuses the alert that now exists and restores the per-app-refusal semantics the bulkhead gave for free the floor is a number needing justification; it protects the capture path only — a customer filling mp0 through app data is untouched by it
B3 — a filesystem quota on the backup directory. XFS project quota / ext4 project quota on <mp0>/sys_drive/backups enforces at the filesystem, so every writer is bounded, not only the capture path recreates a fixed ceiling — i.e. it is R-163 again inside one volume, and D-a's objection to "a bigger number is the same wall further away" applies to it word for word
B4 — R-167's warnings are deemed sufficient. No hard stop; the customer is warned at 85% / 5 GiB and critically at 95% / 2 GiB zero — it is already shipped and proven live a warning is not a bulkhead. It depends on a human acting within the window, and the failure it fails to prevent is "Docker's data-root is full", which is the worst failure on the box

The measured input to that ruling: on demo-felhom mp1 holds 2.0 G against a 50 G ceiling, and mp0 has 175 G free. The overflow scenario is not close today on either box. B2 is the only option that preserves the property the bulkhead actually provided — a per-app refusal with the last good unit intact — and it is the one that reuses what R-158 just built.


M5 — existing boxes, and what a migration costs

Read from the hub's own registers, 2026-08-02. No box was contacted.

The hub's /hosts register holds four hosts, of which two are ONLINE:

host customer agent status
demo-felhom-8363b5 Demo Ügyfél 0.119.0 ONLINE
demo-hp-bb76ea Demo HP 0.119.0 ONLINE
drill-r50-0a4f9a drill-r50 0.113.0 DOWN
sess-f-2670b5 R-120 golden 0.186.0 proof 0.116.0 DOWN

The customer register lists five customersdavid, demo-felhom, demo-hp, peti-felhom, sess-f.

THE FINDING THAT CHANGES THE COST: peti-felhom EXISTS AS A CUSTOMER BUT HAS NO HOST IN THE REGISTER. Consistent with the long-standing "guest not on agent node" stop in the Peti-return runbook. So:

  • The two demo boxes are the entire measurable migrated population, and both are Tier 0 — disposable, per decision D-d. For them a "migration" is not required at all: they can be reinstalled from a merged golden, which is cheaper and lower-risk than migrating in place, and D-d explicitly permits it.
  • D-a's condition (1) — "it must land before any external install" — is currently SATISFIED. No external box appears in the hub's host register. This is the cheapest this decision will ever be, and the window is open now.

UNMEASURED, and reported as unmeasured rather than omitted: Peti's box's actual disk layout. It does not report to the hub, so the hub holds no pct config for it, and it is protected by D-d and target-selection.md — so it was not contacted. Whether a merged golden implies an in-place migration for that box, and what that costs, is not established by this spike. Nor is whether the box is currently restorable at every point of such a migration. Both are inputs the operator has and this session does not.

Also unmeasured: the per-box in-place migration procedure itself (move <mp1>/felhom-data onto mp0, drop the mp1 slot, verify) has not been executed even once on a throwaway guest. If an in-place migration is ever needed, that rehearsal is the first thing to do, and its "is the box restorable at every point?" answer is currently unknown.


Ranked options, and a recommendation

Ranked by what the measurements support, not by preference.

  1. S1 (one volume, two directories) + B2 (a refusal threshold in the capture path), shipped as a FRESH-INSTALL shape, with the demo boxes REINSTALLED rather than migrated. It is what D-a describes; it genuinely removes the ceiling rather than moving it; the four golden assertions and the two vzdump guards are a bounded, greppable edit; the archive scope stays complete; and B2 restores the per-app refusal that is the bulkhead's real value, reusing the alert R-158 just wired. The migration cost for the measurable population is zero, because both boxes are Tier 0 and reinstallable.
  2. S1 + B4 (warnings only). Cheapest, and everything it needs is already shipped and proven live. Rejected as the recommendation only because the failure it declines to prevent — a full Docker data-root — is the worst one on the box, and it depends on a human acting inside the window.
  3. S3 (grow mp1, keep the split). Zero structural risk, one number. It is the measured baseline and D-a rejected it; recorded so the decision is compared against something.
  4. S2 (two mounts, one pool). Not recommended at all. It keeps both assertions satisfied while delivering none of the benefit, and converts a clean per-app refusal into a shared-pool exhaustion neither df can see coming — strictly worse than today.

Two things to do BEFORE the merge session, both cheap, both currently unmeasured:

  • Run one restore-test of a PRE-MERGE archive into a merged-layout guest on a Tier-0 box. M3 reasons it passes; nothing has executed it. This project's own record is that reasoning from source about an unvalidated mechanism has been wrong four times.
  • Rehearse the in-place migration once on a throwaway guest, and record whether the guest is restorable at every point of it. Only needed if Peti's box turns out to require migrating rather than reinstalling — which is the operator's information, not the hub's.

THE QUESTION FOR THE OPERATOR

Which shape, and what replaces the bulkhead?

The recommendation is S1 + B2, fresh-install shape, demo boxes reinstalled. The two open inputs only the operator has are:

  1. Does Peti's box need an in-place migration, or can it be reinstalled? The hub cannot answer this — the host is not in its register. It decides whether the migration rehearsal is required work or optional insurance.
  2. Is B2's per-app refusal the right replacement for the bulkhead, or is B4 (warnings only) acceptable? B2 costs a small amount of code and a justified floor; B4 costs nothing and is already live, but accepts that a full Docker data-root is reachable.

STOP. The merge is next session's work, and it is a supervised one.