Commit Graph

937 Commits

Author SHA1 Message Date
admin c102832892 docs: R-86 closed and proven live; ep0 recorded as protected; R-185/186/187 filed
gates / gates (push) Successful in 8s
- OPEN-ITEMS: R-86 CLOSED with the trap in its own wording recorded (the literal
  reading is never true on a daily tier); R-87 re-ranked UP because R-86 built
  most of what it waited for; R-185 (the agent cannot list demo-felhom's host
  backup tier — a missing storage ACL, pre-existing), R-186 (a released binary's
  sha is not reproducible from its tag), R-187 (R-115's publish leg had never
  actually run) filed. R-184 was the highest ID in use.
- ROADMAP: R-86 collapsed, keeping the reasoning and correcting the shape the row
  itself proposed — which would have been the never-fires version.
- 07-backup-architecture: new contract section — restore-testing is per ARCHIVE
  GENERATION, with the trap and what did not change (S-1).
- 00-capability-map: the unattended restore-proof row upgraded to PROVEN-LIVE on
  the 635 s due-triggered offsite run, with the restart and teardown evidence.
- CONTEXT: S-17 (the rule, the trap, the config key, the hub's derivation) and
  S-18 (ep0 is Tier 2 — extends D-d's protected list to three machines).
  Numbered 17/18 because S-14 and S-15 were already duplicated in the file.
- STATUS: rewritten for the operator, trimmed back to one screen.
2026-08-03 15:28:32 +02:00
admin ff2655cf19 manifests: hub 0.91.0 -> 0.91.1 (R-86: observation may only widen a tier's window)
gates / gates (push) Successful in 7s
2026-08-03 15:18:00 +02:00
admin 046df303b6 hub v0.91.1 — observation may only WIDEN a tier's window, never tighten it (R-86)
gates / gates (push) Successful in 7s
Found by checking v0.91.0 against the live box, not by review. demo-felhom's two
retained PBS snapshots sit 8h54m apart (one is a healing artefact), so the
mean-gap estimator reads a WEEKLY tier as nine-hourly: x4 = 36h, the 7-day floor
lifts it to 168h, and a weekly tier proved weekly reaches ~8.25d of proof age.
The false alarm this task exists to prevent would have returned within a week, on
the box it had just shipped to.

restoreProvenWindow now takes max(observed, declared). A gap SHORTER than the
declared rhythm is routine and means nothing (a retry, a manual run, a heal, a
catch-up); a gap LONGER than it is real information. Cost stated: a tier running
faster than its declared rhythm gets a slower stale signal — the right direction
for a signal that means 'unverified', since 'broken now' is a different event.
2026-08-03 15:16:59 +02:00
admin 687fedd8ee manifests: hub 0.90.1 -> 0.91.0 (R-86 Part 2, per-tier restore-proven window)
gates / gates (push) Successful in 7s
2026-08-03 15:07:52 +02:00
admin 323f45a5ef hub v0.91.0 — the staleness window learns each tier's own rhythm (R-86 Part 2)
gates / gates (push) Successful in 7s
Ships WITH agent v0.121.0, not after it. The agent now proves a tier once per
ARCHIVE GENERATION, so a weekly tier is proved weekly — in perfect health. The
flat 7-day restoreProvenStaleAfter derived its number from the 24h cadence R-86
removes, and a healthy weekly tier's proof age reaches EXACTLY 168h just before
its next proof: it sat ON the line, so any ordinary delay tipped it into a
nightly alarm about a working system.

restoreProvenWindow(tier, observed, ok):
- the tier's own archive interval, OBSERVED from reports the hub already holds
  (pbs_snapshots + successful backups attributed by TARGET TYPE, slice A.4)
- x4 generations = the same tolerance the flat constant expressed
- floored at 7d (never tighter than before), capped at 12d (strictly inside the
  2-week offsite retention)
- falls back to the DECLARED rhythm (26h host / 8d offsite — the thresholds the
  backup-freshness checker already uses) when history is too short to observe
  one; falling back to the FLOOR would recreate the false alarm on a fresh box

Kept: absence is UNKNOWN until the anchored window passes; the signal stays
edge-triggered; failed and stale remain distinct events. Every reason string now
states the window it was judged against (R-100's corollary).

Also backfills the missing v0.90.1 CHANGELOG entry (deployed since f21e7ca), and
records the operator's 2026-08-03 ruling that ep0 is Tier 2 / protected.
2026-08-03 15:03:35 +02:00
admin e34b614e5b docs: R-182 closed, R-90 closed on measurement, R-86 unblocked, ep0 record corrected
gates / gates (push) Successful in 7s
R-182 CLOSED (controller v0.194.0 + hub v0.90.0/.1), proven live on demo-hp. The
hub's notification_log for the run reads: two per-app failures RECORDED, one
digest SENT naming both, and the customer channel SKIPPED with operator_only.
Against the measured previous behaviour — two failures, one email naming one
app, one leaving no trace anywhere.

Scenario D proved itself on an event I had not planned: disk_critical alarmed on
two filesystems, the second was collapsed by the cooldown, and that collapse is
now visible WITH ITS KEY. Yesterday it would have left nothing at all.

A gap the spec did not anticipate is recorded with its fix: the per-app event
also fires from the periodic sweep, outside any run, so making it record-only
would have created a NEW silence. The sweep emits a digest too, with no run_id,
so it stays under the ordinary hourly cooldown.

ep0: MEASURED on the box — 7757 MB (8 GB), 4 vCPU, and the 4 GiB swapfile
SURVIVED the resize and is active (checked, because a resize is a stop/start).
The 40 GB local disk is UNCHANGED, so no disk figure was touched anywhere.

Five documents corrected — three of which the task's list did not name, found by
searching. Two audit/evidence documents ANNOTATED, body untouched: they record
what was true when written and that is their value.

R-90 CLOSED. R-86 unblocked and re-ranked, stated honestly: 8 GB is comfortable,
not unbounded — the original OOM was a 14.46 GB restore — so the restore-test
cadence should still be paced, just not by fear of the endpoint.

target-selection.md's "D-d did not name ep0 either way" is deliberately left
standing. It is the operator's question, not CC's.

STATUS.md 127 -> 83 lines, items rather than sentences.
2026-08-03 14:00:54 +02:00
admin f21e7caed1 hub v0.90.1 — the digest's per-app lines stop repeating the filesystem figures (R-182)
gates / gates (push) Successful in 7s
Found by reading the first REAL digest, not by design. Every app row ended with
the same usage clause the mail already prints once on its own Filesystem line.
On a two-app box that is untidy; down a list of a dozen it is the same forty
characters twelve times, pushing the part that DIFFERS off a phone screen at
07:00 — the only moment this mail has to work.

The reserve's refusal message is authored for a single-app alert where naming
the filesystem is right, so the message is unchanged; the digest trims the
duplicate when rendering. trimRepeatedUsage removes ONLY an exact
"— <target path>:" suffix, so an unrelated reason is untouched and a reason that
is nothing but the usage clause is left alone rather than emptied.

Also updates TestRecoveryUnitCaptureFailed_NeverReachesTheCustomer, which
required the OPERATOR to be emailed a per-app capture failure. That was correct
when the event was the only signal and is wrong now that it is the record and
the digest is the notification. Its customer-safety claim is unchanged and is
why the test still exists; the operator assertion is inverted with the reasoning
written in place, and R-158's guarantee is shown to have MOVED, not weakened.
2026-08-03 13:54:02 +02:00
admin dd40f85bb8 hub v0.90.0 — a dropped notification leaves a trace, and the backup digest arrives (R-182)
gates / gates (push) Successful in 7s
processOperator's cooldown no longer returns bare. It dropped the event BEFORE
LogNotification, so a suppressed operator alert and an event that never happened
were indistinguishable — from the operator's side and from the hub's own records.
Measured 2026-08-03: nine recovery_unit_capture_failed events arrived, two were
mailed, seven left no row anywhere. That is why the defect took a day to get the
right way round: there was nothing to read.

A suppressed operator event now writes a `suppressed` row carrying the message
and the key that suppressed it. This applies to EVERY operator event, not only
the one that exposed it. It does NOT change the cooldown's duration or semantics.

backup_run_failures: the per-run digest. In allowedEventTypes AND in
operatorOnlyEvents — allowlisting alone does not make an event operator-only,
and FormatCustomerEmail falls back to the raw English message rather than
blocking. A test demonstrates a customer with the type enabled receiving nothing.

recordOnlyEvents: a third routing class — stored and recorded, never mailed.
recovery_unit_capture_failed moves here: it is the record, the digest is the
notification. A register rather than downgrading severity to info, which would
relabel a genuine failure as informational everywhere it is queried.

cooldownRunSuffix: a sibling of cooldownTierSuffix, not a branch inside it, so
tier keeps byte-identical semantics and R-97a's tests are untouched. It makes
the cooldown effectively inert for the digest, which is the intent — a digest is
already rate-limited by construction; the refresh sweep sends no run_id and so
stays under the ordinary hourly cooldown.

The email renders as a list, not a JSON blob. An absent space reading renders as
unavailable, never as zeros.
2026-08-03 13:46:48 +02:00
admin 7dc1744eec Merge branch 'main' of https://gitea.dooplex.hu/admin/felhom.eu
gates / gates (push) Successful in 7s
2026-08-03 13:14:17 +02:00
admin a5cd480280 reports deleted 2026-08-03 13:14:15 +02:00
admin b0b269b28d docs: R-110 + R-115 closed, R-182 re-scoped by measurement, R-183/R-184 filed
gates / gates (push) Successful in 7s
R-182's direction REVERSED by Part 0's measurement. Filed yesterday as "the
reserve re-alerts on every status refresh" — too many alerts, seen at the
sending end. Measured at the receiving end: 9 events received today, 2 operator
emails sent. When two apps are refused in the same second the operator is told
about ONE; the other is dropped before LogNotification, so it leaves no row on
any channel and cannot be audited. The operator cooldown key is
customerID:eventType(+tier) and the capture-failed event carries `app` but no
`tier`, so the key has no app identifier. Same failure mode as R-97a, in a
second event type that never opted into the narrow fix. Nothing changed —
Part 0 was investigation only.

Correction owed: yesterday's report said "one recovery_unit_capture_failed per
app, HTTP 200". True of what the CONTROLLER pushed; a reader would take it as
"the operator was told about each app", which is false.

R-110 CLOSED (installer v1.23.0). Both channels moved. The spec's mechanism for
channel 2 rested on a factual error — the run-time fetches are sixteen, not
nine, and come from felhom-agent, not this repo — so no tag here could cover
them; pinned to the agent version being installed instead, on the operator's
ruling. Channel 3 needed no change: the URL never carried a ref, so no hub
change and no hub bump.

R-115 CLOSED. release-agent.sh builds, tags, publishes and verifies by an
independent download; check-published-versions.py refuses a tag with no package;
CI now runs the full gate set so it actually runs.

R-183 NEW+CLOSED: a fresh install fetched the vouched agent binary and its
sixteen config files from two different refs, and nothing compared them.

R-184 NEW: nothing stops the hub vouching a version that was never released.
The R-115 gate cannot see it — measured, the hub manifest and Gitea's package
listing are both 401 anonymously.

capability map: new PROVEN-LIVE row for the published installer channel.
STATUS.md 138 -> 127 lines.
2026-08-03 12:44:08 +02:00
admin e79a20bbed test(R-110): remove the Scenario A marker — proof complete
gates / gates (push) Successful in 7s
Scenario A: pushed to main without moving the tag; the served bytes stayed
byte-identical (sha 2f859555…) and the marker never appeared, while the website
tree advanced to the new commit — both halves of the split in one observation.

Scenario B: moving the tag published it (~40s, sha ea2b4aa9…, marker present);
moving it back rolled it back to exactly the pre-publish sha with the marker
gone. The website returned 200 throughout.
installer-v1.23.0
2026-08-03 12:27:39 +02:00
admin 6a82719426 test(R-110): Scenario A marker pushed to main WITHOUT moving the tag
gates / gates (push) Successful in 8s
A comment only. It exists to be LOOKED FOR at the served URL: if it appears
there, /scripts/ is still tracking main and the publish channel is still
floating. It must not appear until installer-v1.23.0 is moved.
2026-08-03 12:24:22 +02:00
admin bee6848458 installer v1.23.0 — publishing becomes an act, not a side-effect (R-110, R-183)
gates / gates (push) Successful in 8s
Two channels moved off main in the same change, because either one left behind
makes the other cosmetic.

Channel 1 — the served script. webpage.yaml git-synced /scripts/ from
--branch=main every 30s and nginx served that tree, so pushing this file WAS
publishing it: within half a minute it was what every new machine downloaded and
ran as root, with no staging and no rollback but another push. The sync is now
SPLIT: the website keeps tracking main at the same cadence (a copy edit must
never need a release) and /scripts/ tracks the tag installer-v<SCRIPT_VERSION>.

PROVEN before the manifest was touched: git-sync v4.4.0 follows a tag AND
notices a MOVED one — measured on a throwaway sync against this repo,
"update required ... local:<old> remote:<new>" -> "updated successfully",
within one period. The moved-tag half is what the publish model rests on.

Channel 2 — the sixteen files fetched at run time. fetch_raw pulled from
$AGENT_REPO/raw/branch/main; it now pulls raw/tag/v$ART_AGENT_VER. That is a
correctness fix, not only a channel one (R-183): a fresh install fetched the
vouched agent BINARY while taking its unit file, sudoers and guarded wrappers
from whatever main held. Two refs, one install, nothing compared them. Their
correct ref was never SCRIPT_VERSION — they do not live in this repo.

No fallback to a branch: a vouched version whose tag is missing fails loudly
rather than quietly serving main.

Channel 3 — the URL — needed no change, recorded rather than left silent:
https://felhom.eu/scripts/felhom-host-install.sh never carried a ref, so both
producers follow the tag with no edit. No hub change, no hub version bump.

Gate 6 in hostinstall_gates.py pins all three structurally with no network, so
it stays in --fast and runs in CI. It deliberately does NOT assert "a tag exists
for the current SCRIPT_VERSION": that would go red on the very push that bumps
the version, before publishing — and publishing being separate is the ruling.
2026-08-03 12:08:37 +02:00
admin 8360f940bf docs: seventh row in the shipped-guarantees table (R-181), versioned workspace CLAUDE.md
gates / gates (push) Successful in 7s
Syncs documentation/runbooks/workspace-CLAUDE.md with the workspace root file.
Row 7: the B2 refusal claimed the previous unit was untouched; nothing-deleted
held, untouched was measured false.

Also records WHY it survived review: it passed a full green suite AND three of
its own red-proofs, because every one of them asserted the mechanism inside
captureAllRecoveryUnits and none asserted the consequence across the whole
backup run. The test that would have caught it is the one the fix ships —
fingerprint the tree before and after, and compare.
2026-08-03 11:36:47 +02:00
admin fb652024ea docs: R-181 closed, R-156 closed, R-110 + R-115 rulings recorded, R-182 filed
gates / gates (push) Successful in 7s
R-181 CLOSED (controller v0.193.0 + v0.193.1) and proven live on demo-hp for
BOTH reserve terms. The reserve is now a per-app, per-run ADMISSION decision
taken before the app's first write and covering all three write legs, and it
gained a size term. The refusal's wording was not weakened; the behaviour moved
so it became true, verified by sha256 tree fingerprint.

R-156 CLOSED — papra's template mounts the app's own data root. Precondition
re-measured rather than inherited (both boxes were wiped today).

Part 4, documentation only, nothing built:
- R-110 WAITING-ON-OPERATOR -> READY. Ruling: option (b), the installer's
  publish channel moves to a TAG. Recorded with the condition that decides
  whether it works at all — it must cover BOTH the /scripts/ git-sync and the
  nine files the installer fetches from raw/branch/main.
- R-115 WAITING-ON-OPERATOR -> READY. Ruling: mechanism (b), a build-side gate
  refusing to deploy or vouch an unpublished version. The third instance (agent
  v0.120.0) would have silently downgraded both demo boxes while succeeding.

R-182 NEW: the periodic status refresh has no admission scope, so a refused app
re-alerts on every poll (measured: a second alert pair 13s after the run's).
Pre-existing in v0.192.0; deliberately not fixed in the R-181 task.

capability map: the local-backup row moves to PROVEN-LIVE in BOTH halves.
ROADMAP: R-165 collapses to CLOSED; R-181 collapsed into it.
07-backup-architecture.md: the reserve's contract stated as what the code
provides (S-1 — an architectural contract changed in the same session).
STATUS.md trimmed 150 -> 111 lines, "What's broken" no longer holds shipped
work, and the stale "After:" line (pointing at work that shipped on 2 August)
is fixed.
2026-08-03 11:36:16 +02:00
admin 06cbf8df29 skill(build-deploy): the installer ISO — the one artifact the skill promised and omitted
gates / gates (push) Successful in 7s
The skill's own description claimed 'ANY Felhom artifact' and 'publish', and had no ISO section —
a description asserting coverage that did not exist. Description corrected and a section added.

POINTERS, NOT COPIES. The 13-criterion release gate stays in
documentation/runbooks/iso-release-gate.md and the measurements stay in the four spike audits;
duplicating them into a skill guarantees drift (the R-94/R-128 class). What the skill adds is the
ROUTING that was missing: nothing told anyone the gate exists, which is R-29's exact shape.

Records the two modes (--release public vs --pairing appliance) because picking the wrong one ships
the wrong product, the build and publish commands (rclone env-only, so no credential file is ever
written), and the round-trip verification.

The traps it carries existed only in commit messages until now, and each cost a wrong diagnosis:
  - 'qm set --scsi0 ... --boot order=' in ONE call silently yields boot: order=net0;ide2
  - after install the CD must be detached, or a COMPLETED install looks exactly like a stuck one
  - verify focus by screendump before every Enter (GTK Enter lands in fields, not Next)
  - proof installs register appliances; the verb is POST /appliances/<id>/discard, not /delete
  - scratch storage at the /mnt/nvme-1tb ROOT (a subdirectory reads disconnected forever)

Also flags that the ISO gate is NOT wired into repo_gates.py, so nothing reminds you to run it.

Docs only. python3 scripts/repo_gates.py --fast: all gates OK (rc=0).
2026-08-03 10:54:33 +02:00
admin 6b5d64c1fa REPORT: CI run ids and conclusions for both commits (46, 47 — both success)
gates / gates (push) Successful in 8s
2026-08-03 09:35:16 +02:00
admin aa62449694 R-178 CLOSED: both demo boxes reinstalled from the merged golden and proven
gates / gates (push) Successful in 8s
Two boxes, two DIFFERENT supply paths, so the session proved the disk shape and
the delivery route rather than one of them twice.

demo-hp (layout proof, --golden <local volid>): mp0 at /var/lib/felhom,
backup=1, 70G, no mp1; /var/lib/docker and /mnt/sys_drive both real mounts of
its subdirectories via fstab; one df figure and one device id (64519) on all
three paths; reboots 3/3 with the binds surviving each.

demo-felhom (pipeline proof, --force-gitea-golden): fetch_verify succeeding
against the vouched manifest for BOTH artifacts -- 'verified sha256
54e2a4c431daf580... matches the hub manifest' for the golden, a7763d31... for
the agent. 250G single volume, grep -c '^mp1:' = 0, reboots 3/3.

Journey proven on both, endpoint-level: claim -> deploy -> back up -> restore,
with a planted marker returning byte-identical on each box. Ceiling measured
gone: 65 GiB and 233 GiB available to a recovery unit, against 19 and 45.

R-165 -> IMPLEMENTED, not PROVEN-LIVE, on the operator's ruling. B2, which that
row records as the bulkhead's replacement, fired live for the first time and
does refuse per app, delete nothing and alert -- but it is checked only in
captureAllRecoveryUnits while runVolumeDumps writes the bulk unguarded, and its
'the previous unit is untouched' claim was measured false (182,272 B dump
replaced by 2,147,666,432 B under a manifest still dated 06:34:26). -> R-181.

New: R-179 (uninstall leaves NAS network-storage units), R-180 (--archive-storage
not cross-checked against the ACL grant; 403 at step 8/8 after root@pam is
rotated), R-181. Third instance of R-115 recorded (agent 0.120.0 unpublished).

No code written, no version bumps -- this was a runbook.
2026-08-03 09:34:15 +02:00
admin bdd1a9d130 REPORT: CI run ids and conclusions for all six commits (all green)
gates / gates (push) Successful in 7s
2026-08-03 07:16:44 +02:00
admin 14d8c00781 docs: R-165 merge built and proven at the bake; R-163 + R-175 closed, R-178 filed
gates / gates (push) Successful in 8s
07-backup-architecture.md gains §7.5.1 (S-1: the contract changed in the same
session): the ceiling §7.5 describes no longer exists for a box built from
golden >= 0.192.0, the bulkhead's replacement is recorded, and R-175 is FIXED
here rather than left standing — the bound is restated as a function of mp1
and scoped to split-layout boxes, naming all three real shapes.

Capability map: new row as IMPLEMENTED, deliberately NOT proven-live, with
the missing leg named — no box has been reinstalled from the golden, and
"the golden baked" is not "a box built from it works".

R-163 CLOSED: the ceiling it recorded stops existing. R-176(a) answered by
P1; (b) WITHDRAWN, since every node is reinstalled rather than migrated.
R-178 filed for the reinstalls, which were not done this session.

CONTEXT S-13 (the variant chosen on measurement; pruning rejected with its
reason) and S-14 (prove first, then vouch — the golden is published but
deliberately unvouched, because vouching is what makes a fresh install pick
up a layout no box has been proven from).

STATUS: plain-language section; both operator questions now answered, so the
waiting-on-you item is cleared. Two older entries trimmed so the page did
not grow.
2026-08-03 07:16:15 +02:00
admin e3525e62ac host-install: one data volume, derived from the disk (R-165)
gates / gates (push) Successful in 7s
felhom-agent v0.120.0 merges the two data volumes into one, and step_grows
computed two numbers while the install call passed both — so this had to
change with the agent or every install would have provisioned a half-sized
box. The 80/20 split is summed (226 = 184+42), so a standard appliance keeps
exactly the 250 G it had, no longer split by a wall.

The size still comes from the physical disk: step_grows already read the
thin pool's free space, and the merge only collapsed its two outputs into
one. --sysdata-grow is deprecated but still honoured, because the agent
folds a hand-passed value in rather than dropping it.
2026-08-03 06:43:58 +02:00
admin 7406ac7bbf audits: R-165 Phase 0 — P1 and P2 measured, nothing changed
gates / gates (push) Successful in 7s
P1 PASS: a pre-merge archive (mp0+mp1, confirmed from its own vzdump log)
restore-tests clean on demo-hp with mount_parity ok in 84s. mountParity was
not touched. Limit stated: run with the current agent because the merged one
does not exist until after the STOP; the comparison is archive-vs-its-own-
restore and never consults the host layout, so it carries provided Part 2
honours its constraint not to touch the restore path. Re-run after Part 2.

P2: all three probed variants are mechanically clean — both paths writable,
ONE df figure, dockerd 3/3 reboots, /mnt propagation, and a container's
statfs(/) reporting the merged volume. The task's flagged ordering risk for
V-b did not materialise. They are separated by SCOPING instead:

  V-a  container sees /mnt = 8.0K  but customer data sits inside Docker's
       data-root, so clearing /var/lib/docker destroys every local unit
  V-b  container sees Docker's ENTIRE data-root under /mnt (17.9M on an
       empty box), making the bootstrap's own scoping comment false
  V-c  neutral mount at /var/lib/felhom, both paths binds — breaks neither

V-c was probed because the measurements showed each named variant violates
a different documented invariant. It is offered as a measured option for the
operator, NOT adopted.

Teardown all three layers: 9401/9402/9403 destroyed, 5.19 GB returned, and
the hub registers verified unchanged (5 customers, 4 hosts).
2026-08-03 06:34:26 +02:00
admin 1806dfa8e9 REPORT: CI run ids and conclusions for all six commits (all green)
gates / gates (push) Successful in 7s
2026-08-02 23:56:45 +02:00
admin 41dbecb264 docs: R-167 + R-158 CLOSED, R-165 SPIKED, R-174..R-177 filed
gates / gates (push) Successful in 8s
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
admin 6d359a5360 manifests: hub 0.88.0 -> 0.89.0 (R-167/R-158 event routing)
gates / gates (push) Successful in 7s
2026-08-02 23:21:17 +02:00
admin 179dd79882 hub v0.89.0 — the two halves of decision D-c (R-167, R-158)
gates / gates (push) Successful in 7s
New OPERATOR-ONLY event type recovery_unit_capture_failed (controller
v0.191.0, R-158): in allowedEventTypes AND notify.operatorOnlyEvents.
Deliberately not a reuse of backup_failed, which carries customer copy and
sits in the controller's DefaultEnabledEvents — reusing it would email the
customer in Hungarian about a failure they cannot act on. R-158's own
proposal said backup_failed; D-c overrides it.

disk_warning/disk_critical lose their generic customerMessages entries.
Both were allowlisted, copy'd, default-enabled and checkbox'd with NO
producer anywhere; controller v0.191.0 becomes that producer and sends a
DYNAMIC Hungarian message naming the drive and its free space.
FormatCustomerEmail prefers the entry over the message, so keeping a static
entry would discard the label and the byte figures — the same reason
offbox_enlarge_blocked and disk_health_degraded have none. The deletion is
pinned by a test.

New notify.IsOperatorOnly so the api package can pin BOTH registers of a new
event type in ONE test; allowlisted-but-not-operator-only is invisible when
they are checked separately, and it is the defect v0.78.0 shipped. The
register itself stays unexported.

REUSE.md's "new event type" extension point rewritten: it told readers to
always add a customerMessages entry, which is wrong for operator-only types
and harmful for dynamic-message ones.

Tests 574 -> 579. Red-proof: removing the operatorOnlyEvents entry shows the
customer being emailed; the skipped/operator_only row is asserted as a
positive observable.
2026-08-02 23:19:13 +02:00
admin 8ef92a3fa7 docs: R-172 CLOSED (hub v0.88.0), R-173 filed, session report
gates / gates (push) Successful in 7s
R-172's root cause was not tuning — the WAL/busy_timeout pragmas had never been
applied, because the DSN used mattn/go-sqlite3 syntax against modernc.org/sqlite,
which ignores unknown parameters without an error. Recorded that way so nobody
re-reads it as "SQLite was slow".

R-173 NEW: while establishing who copies hub.db for the WAL change, found
pvc/hub-data labelled recurring-job-group.longhorn.io/default: disabled, with
backup-daily and backup-weekly the only recurring jobs and both on the default
group — so the hub database has no volume-level backup, and it holds every box's
break-glass root password plus the escrow custody records. Filed, not fixed:
whether the exclusion is deliberate is an operator question.

The session report is REPORT-r172-hub-wal.md, not REPORT.md, per the
parallel-session rule — REPORT.md belongs to the controller session that ran
immediately before this one.

It also records, plainly, that a 60-concurrent load test I ran OOM-killed the hub
pod three times against a 256Mi limit. Not the WAL change, and not a test I
should have run against a Tier-2 box; the unit tests already proved the property.
2026-08-02 21:22:14 +02:00
admin d5774d3189 manifests: hub 0.87.0 -> 0.88.0 (R-172 WAL fix)
gates / gates (push) Successful in 8s
2026-08-02 21:08:17 +02:00
admin 0fc54e0122 hub v0.88.0 — the WAL that never was (R-172)
gates / gates (push) Successful in 7s
store.New opened the DB with `?_journal_mode=WAL&_busy_timeout=5000`, which is
mattn/go-sqlite3 syntax. The driver is modernc.org/sqlite, whose applyQueryParams
reads only _pragma/_time_format/_time_integer_format/_txlock/_inttotime and
IGNORES anything else WITHOUT AN ERROR. So the hub ran in rollback-journal mode
with busy_timeout=0 for its entire life while its own source said otherwise.

Surfaced as a false HOST STALE banner: in rollback-journal mode a reader excludes
a writer, so rendering an operator page blocks a host report; the hub 500s, the
agent waits its full 15-minute interval without retrying, and staleness fires at
30 minutes — two collisions is a false alarm plus an operator email. 13 collisions
in one pod lifetime; the alarm fired twice on 2026-08-02 for a host that was up
two days and reconciling throughout.

The observable that proved it: a 128 MB /data/hub.db with no -wal/-shm beside it
while the DB was open.

Fix: ?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_txlock=immediate.
_txlock=immediate is not optional — database/sql's Begin() is DEFERRED, so a
read-then-write tx must upgrade its lock and a failed upgrade is
SQLITE_BUSY_SNAPSHOT, which busy_timeout does NOT retry; this store has 10+
db.Begin() sites and they are all write paths.

Every test asserts what the DATABASE reports, never the DSN string — a string
test would have passed for the whole life of the bug. Red-proof: restoring the
shipped DSN reproduces journal_mode="delete", the missing -wal, and the live
"database is locked (5) (SQLITE_BUSY)".

Operational consequence handled: a WAL DB cannot be copied by taking hub.db
alone — a bare `cat` opens cleanly and silently omits the newest writes. The
break-glass retrieval in operations/nodes.md used exactly that; it and the
recovery-inventory note are now WAL-aware.
2026-08-02 21:06:29 +02:00
admin 2c35c4204a OPEN-ITEMS: R-172 — false host_stale when SQLite refuses two consecutive host reports
gates / gates (push) Successful in 6s
The hub's /data/hub.db (128 MB) is in rollback-journal mode, not WAL, so a UI
render can block a report write; the hub returns 500 on SQLITE_BUSY without
retrying, and the agent waits its full 15-minute interval rather than retrying.
Staleness fires at 30 minutes, so two consecutive collisions produce a false
host_stale and an operator email for a healthy host. Observed twice on
2026-08-02 while the agent was up 2 days and reconciling throughout.

Pre-existing: 13 collisions in one pod lifetime, first ~3h before that day's
controller work, though a burst of restarts amplifies it.
2026-08-02 20:48:04 +02:00
admin ad28699761 docs: R-157 A / R-170 / R-171 closed — boot recovery finished
gates / gates (push) Successful in 7s
Controller v0.190.0. Docs only here; no hub change, no hub version bump.

- audits/DIAG-bootrecon-drive-absent-2026-08-02.md — NEW. The Part 0 diagnosis,
  including the run that produced a FALSE NEGATIVE and the mechanism behind it
  (the agent re-binds an unmounted drive within ~60s, so the drive gate's startup
  reconcile restarted the apps one second before the sweep looked). Records that
  the write hazard was blocked only by an ACCIDENTAL filesystem permission that no
  code owns and no test pins.
- architecture/02 §0a — the boot-recovery contract (S-1): both gates read desired
  state; the sweep observes a SETTLED fleet and each sample must refresh first;
  nothing is started without asking, fail-safe. Plus the durable warning:
  Manager.StartStack has no gate of its own.
- 00-capability-map — the boot-recovery row, with the repeat count cited per N.5
  (6 of 6 hard resets) rather than a bare PROVEN-LIVE.
- OPEN-ITEMS / ROADMAP — R-157 CLOSED (both mechanisms), R-170 CLOSED, R-171 NEW
  and closed the same session, marked a regression from v0.189.0.
- STATUS.md — the power-cut line moved from "What's broken" to "What works right
  now" with its repeat count; one dated bullet in the change log.
- CONTEXT.md S-13 — the lessons worth carrying: "it didn't happen this time" is
  not a disproof; widening a window makes previously-unreachable overlaps
  reachable; and a settle detector is only as good as the freshness of what it
  samples — the fix's own defect, found live rather than by review.
2026-08-02 20:38:21 +02:00
admin 5c97fbc397 docs: R-166 SHIPPED — the desired/in-flight/observed split (D-b)
gates / gates (push) Successful in 8s
Controller v0.189.0 implements operator decision D-b. Docs only here; no hub
change and no hub version bump.

- architecture/02-controller-module-map.md §0a — NEW, and it is the S-1 contract:
  desired (app.yaml) / in-flight (own marker file) / observed (not persisted),
  with the rule that ties them — never derive one from another. Absent desired
  state means UNKNOWN, never "running". One file, one writer. D-b's binding
  safety rule quoted verbatim.
- 00-capability-map.md — the boot-recovery row now rests on a recorded signal,
  with the three live flows from 9201. The interrupted-operation half is marked
  IMPLEMENTED, not PROVEN-LIVE: nobody killed the controller mid-backup on metal.
- OPEN-ITEMS/ROADMAP — R-166 SHIPPED with both blocking facts and their answers;
  R-157 mechanism B CLOSED and A restated as the whole item; R-170 NEW (the
  drive-backed boot gate still infers a Stop from a container count).
- STATUS.md — the "an app can stay switched off and nothing says so" line
  rewritten to what is actually left: timing.
- CLAUDE.md — end-of-session checklist gains: confirm your own last push's CI run
  went green, BY RUN ID. The failure email is a push signal; this is the pull check.
- CONTEXT.md S-12 — the rulings, and the two lessons worth carrying: a test that
  constructs the thing it should prove the caller constructs is hollow (its
  red-proof will say so), and a field-by-field struct rebuild in a save path is a
  defect on sight.
2026-08-02 18:58:27 +02:00
admin c04ea4f6c2 REPORT: record that Gitea soft-deletes runners, and the query trap that hid it
gates / gates (push) Successful in 7s
2026-08-02 16:38:35 +02:00
admin ca3c8f6784 REPORT: record how the session token was revoked, and what was tried first
gates / gates (push) Successful in 7s
2026-08-02 16:36:59 +02:00
admin c718aad1bc docs: R-168 SHIPPED, R-29 CLOSED on the demonstrated alarm, R-169 minted
gates / gates (push) Successful in 7s
SPIKE-ci-runner-2026-08-02.md: all six probes with method, measurement and ruling; none
STOPped. P2 (stock image has git but no python3) and P6 (a runner that loses its state
re-registers and orphans the old record) changed the design; P5 (a failed run signals
NOTHING) is why the alarm exists at all.

R-168 SHIPPED with its evidence. R-29 CLOSED — on the demonstrated alarm and not on a green
run, as required: the class it opened is answered at both ends, the hook refusing locally and
CI catching a --no-verify bypass and emailing. R-161 noted: its automatic half now exists for
the STATIC gate, while its original scope, the runtime gate, is deliberately still not
automatic and should stay that way.

NEW R-169 (grep established R-168 was the highest in use): CI can only report, because there
is no gate in the road. Making it blocking needs branch protection plus a PR workflow, both
of which change how the operator works — so it is theirs to decide, and the row states the
cost honestly rather than recommending it.

CONTEXT gains S-8 (CI detects, does not block, and why that is structural), S-9 (a detector
that tells no one is not finished, plus the curl and Cloudflare-1010 traps), S-10 (the runner
is unprivileged because DooPlex is Tier 2), S-11 (CI reproduces the sibling layout).

CLAUDE.md gains the rule earned by red-proofing: a go test -run pattern that matches no test
prints ok and exits 0, and an instrument that can silently drop results is not a measurement.
2026-08-02 16:35:34 +02:00
admin 4cc123809c revert the Scenario B breakage — main is green again
gates / gates (push) Successful in 7s
The deliberate hostInstallVersion const is removed. It existed only to produce a real red
run (#3-#6) and the demonstrated alarm; R-94's deletion stands.
2026-08-02 16:26:30 +02:00
admin 9530de722d ci: the alarm needs a User-Agent — Cloudflare 403s python-urllib
gates / gates (push) Failing after 8s
Resend sits behind Cloudflare, which blocks the default 'Python-urllib/3.x' agent with its
own 403 (error 1010). That failure looks exactly like an auth failure and is not one, so
the reason is recorded next to the header. Verified from the runner image with a
deliberately invalid payload: with the agent set, Resend answers 422 missing-field, i.e.
the request now reaches the API.
2026-08-02 16:25:58 +02:00
admin f7dbc335ac ci: send the failure alarm with python3/urllib, not curl
gates / gates (push) Failing after 8s
The first version died on 'curl: command not found' — the runner image carries python3 and
git and nothing else on purpose. Reaching for a bigger image to send one HTTP request would
have been the wrong trade, so the step uses urllib. Verified from the image itself that
HTTPS to api.resend.com resolves and the certificate verifies.

The step also fails LOUDLY on an empty key or a non-2xx from Resend: a silent alarm is
worse than no alarm, because it reads as coverage.
2026-08-02 16:25:17 +02:00
admin dd13f632c8 ci: a failed run sends its own alarm (R-168, probe P5)
gates / gates (push) Failing after 7s
P5 measured: a failed run produces NO mail, NO notification row and NO log line from Gitea.
A red tick in a web UI nobody watches is exactly the defect R-29 filed, rebuilt one layer
up, so the run alarms itself on the project's existing transactional path (Resend, the same
one the hub uses) and prints the provider's accepted id, making 'it was sent' an observable
rather than an assumption.

The key is a user-level Gitea Actions secret created out-of-band; it is in no committed
file. The recipient is the operator address the hub already uses and is not a secret.

This push is deliberately made while main is still carrying the Scenario B breakage, so the
resulting run fails and demonstrates the alarm end to end.
2026-08-02 16:23:56 +02:00
admin 3252d51104 SCENARIO B: deliberately break the hostinstall gate (reverted immediately)
gates / gates (push) Failing after 7s
Pushed with --no-verify ON PURPOSE: this simulates exactly the bypass that CI exists to
catch. The local pre-push hook would have refused this commit.
2026-08-02 16:21:35 +02:00
admin 666a34da88 ci: run the gate entry point on every push (R-168)
gates / gates (push) Successful in 7s
Replaces the Part 0 probe workflow, whose four measurements are recorded in
documentation/audits/SPIKE-ci-runner-2026-08-02.md.

Reports, does not refuse: pushes go straight to main with no pull request, so there is no
merge for a status check to stand at. The refusing half is .githooks/pre-push, which is
per-clone and --no-verify-able; this half notices when that was skipped.

No uses: step anywhere — JavaScript actions need a node runtime the host-mode runner does
not have. Probe P3 measured that a shallow git fetch of the exact pushed SHA from the
in-cluster Gitea service is sufficient, and that it equals the pushed commit.

The alarm step is deliberately absent until probe P5 measures whether Gitea already mails
on a failed run.
2026-08-02 16:20:43 +02:00
admin bbd6231909 probe: temporary CI workflow for R-168 Part 0 (P1/P2/P3 + Scenario E)
probe / probe (push) Successful in 48s
TEMPORARY. Deleted before the session ends. Measures whether a registered runner picks up
a job at all, whether python3 and git are visible to the JOB (not merely present in the
image), whether the source can be obtained with no JavaScript action, and that docker is
NOT reachable from a job.
2026-08-02 16:16:59 +02:00
admin af2d103880 REPORT + STATUS: gate enforcement session, hub v0.87.0 live
REPORT overwritten per the standing rule; every red-proof, the core.hooksPath probe's four
measured outcomes, Scenario C's refusal-and-bypass, the hub deployment and the live Setup-tab
read are recorded there, plus three observations and two deliberate deviations from the spec
(a comment-only edit to felhom-host-install.sh, and __pycache__ in .gitignore).

STATUS: the 'check that needs a person to remember it' line is rewritten — the checks now run
themselves before every push, with both honest limits stated in plain words; and one entry
records the thirteen-check cleanup and the deleted installer version number.
2026-08-02 15:37:19 +02:00
admin 8d9b78c153 manifests: hub 0.86.0 -> 0.87.0 (R-94, the Setup tab renders no version) 2026-08-02 15:29:51 +02:00
admin 4707be755c docs: R-94 closed, R-29 leg (a) closed + leg (b) half, R-168 minted
hub/CHANGELOG v0.87.0 + scripts/CHANGELOG gate-enforcement entry. CONTEXT gains S-6 (the
hub renders no host-install version and the gate pins its absence) and S-7 (gates run from
one entry point per repo; reuse_refs_check was fixed rather than the REUSE.md convention,
with both rejected alternatives recorded).

OPEN-ITEMS: R-94 CLOSED all three legs, leg (a) by DELETION with its reason; R-29 leg (a)
CLOSED and leg (b) HALF-SHIPPED with the census result written into the row (13 gates; every
gate a CLAUDE.md names was green, two of the four unnamed were red); R-161 gains its
successor pointer. NEW R-168 (grep established R-167 was the highest in use): Gitea Actions
runner — measured 2026-08-02 as Gitea 1.26.2, Actions enabled on all four repos, 0 runners,
0 workflow runs, 0 branch protections, and the consequence that trunk-based direct-to-main
pushes leave no merge for a status check to gate, so CI here can detect but not block.
BLOCKED on a spike over host-mode vs privileged DinD on DooPlex and whether the workflow can
avoid JavaScript actions.

ROADMAP: R-94 collapsed to its one-liner, R-29 updated, R-168 added.
2026-08-02 15:28:31 +02:00
admin 9bd1a54d71 gates: one entry point (scripts/repo_gates.py) + pre-push hook
A census of all thirteen gate scripts across the four felhom repos on 2026-08-02 found one
clean correlation: every check a CLAUDE.md tells a person to run was passing, and two of the
four nobody is told to run were failing — one since 14 July. Neither failure was harmful in
effect (checked line by line); nothing would have said so if they had been. The fix is not
more gates, it is one place to run them from.

repo_gates.py runs site + hostinstall + hub-confirm + manifest-bearer + reuse-refs, streams
each gate's own output, and exits worst-wins non-zero. A missing gate script is a FAILURE and
prints the path tried — fail-closed, because a runner that quietly skips a gate is the
inert-seam failure this project has shipped four times. It copies catalog_gates.py (R-161),
NOT site_gates.py, which is a gate and not a runner.

.githooks/pre-push runs it with --fast and refuses the push. Honest limits are written into
the hook itself: per-clone (core.hooksPath is local config), and --no-verify bypasses it on
purpose. Any manual run WARNS when the clone is unarmed. Measured on git 2.47.3: a relative
core.hooksPath resolves correctly and the hook's cwd is the repo root from any subdirectory.

test_repo_gates.py is a SEAM test — it asserts each member gate's own distinctive stdout, not
the runner's summary line, which an inert runner prints while calling nothing. Red-proofed:
replacing run_gate's body with 'return 0' still prints 'all felhom.eu gates OK' and exits 0,
and turns the seam test red.
2026-08-02 15:22:44 +02:00
admin 2137094799 scripts: reuse_refs_check resolves package shorthand and sibling repos
RED on all four repos with 13 findings, and a hand audit of all 13 on 2026-08-02 found
ZERO genuine drift: twelve were package shorthand whose file sits a couple of directories
deeper, and one (wgsync/reconciler.go, cited by the controller) lives in the hub. REUSE.md
cites by package shorthand and across repos on purpose; the tool was what was wrong.

Resolution order, first hit wins, every non-exact hit PRINTED so a weakening is visible:
exact / suffix / ambiguous (real citation, imprecise shorthand — not a failure) / sibling
repo (as-is or with the sibling's own name stripped from the token) / FAIL. A failure lists
every resolution attempted, so a 'not found' claim names what was tried. Per-root tallies
are the positive observable: '0 failures' alone cannot tell a working checker from a blind
one. Evidence trees (audits/, documentation/tests/) are excluded from the suffix index — a
copy of a file is not the file. An absent sibling is never a failure; an unreadable parent
says so and continues.

Result: 13/13 resolve, all four roots exit 0. felhom.eu 60 exact + 1 suffix; controller 126
exact + 6 suffix + 1 cross-repo; agent 88 + 1 + 1; catalog 17 exact + 3 cross-repo.

New scripts/test_reuse_refs_check.py: 13 fixture tests, one per resolution row plus the kill
condition. Red-proof: making resolve() return 'exact' for an unresolvable token turns 4 of
them red.
2026-08-02 15:16:11 +02:00
admin d319ae573e hub: delete the host-install version label (R-94) + invert hostinstall gate 1
The Setup tab said 'host-install 1.19.0' while the served script was 1.22.0, and had
been wrong since 2026-07-14. Deriving the number honestly is not possible: the Option-1
command downloads felhom-host-install.sh from the website at RUN TIME and the website
git-syncs main every 30s (R-110), so no build-time value in the hub can be true. R-94(a)
offered derive-or-delete; deleted, which removes the drift class instead of automating it.

- configs.go: hostInstallVersion const, pageData.ScriptVersion field and its assignment
  all removed; a NOTE in their place records why there is no constant here.
- customer_unified.html: the sentence now says the command always fetches the current
  installer, and renders no version.
- hostinstall_gates.py gate 1: the third assertion INVERTS — it used to require the hub
  const to equal SCRIPT_VERSION, it now asserts the hub carries no host-install version
  literal at all, matched in six code shapes across every .go/.html under hub/ (comments
  are deliberately not stripped: a // inside a URL literal would blind the scan).
- render_test.go: the assertion 'html contains hostInstallVersion' compared the constant
  to itself and passed at ANY value — demonstrated green with the const at 9.9.9 while the
  script was 1.22.0. Deleted, not replaced: there is no longer a version to assert.
- felhom-host-install.sh: COMMENT ONLY (SCRIPT_VERSION untouched) — it claimed the gate
  keeps the hub copy equal, an invariant that no longer exists.

Red-proofs: restoring the const fails the rewritten gate 1 (3 shapes hit); the old
render_test assertion passes at 9.9.9.
2026-08-02 15:16:01 +02:00
admin e994bf35d2 STATUS.md: a plain-language operator page, and today's four decisions recorded
Documentation only — no code, no box, no build.

STATUS.md (repo root, 652 words / 67 lines): what works · what's broken ·
what we're working on · waiting on you · changed since. A VIEW of
OPEN-ITEMS.md, holding nothing of its own; not CONTEXT.md, and both files
now say why they stay separate. No R-n is the subject of a sentence —
identifiers are bracketed pointers only.

CONTEXT.md S-5 records the four operator decisions taken 2026-08-02
(D-a … D-d), none of them implemented:
  D-a merge mp1 into mp0 rather than resize it — before any external
      install, and D-c ships in the same step        → R-165
  D-b desired/observed app state in its own store, with the state-store
      safety rule verbatim                           → R-166 (BLOCKED)
  D-c customer fill warning + operator backup-failure alert → R-167
  D-d only DooPlex and Peti's box are protected      → target-selection.md

R-163 RE-FRAMED, not closed: the sizing question is withdrawn rather than
answered; the row survives as the record of the constraint until R-165
lands. R-156's papra referral RESOLVED — deployed nowhere, so the template
fix strands nothing; the docker ps evidence is recorded with its
provenance and its scope limit.

target-selection.md: two protected machines, everything else disposable.
ep0 is no longer Tier 2 but is not scratch (it holds the only off-premises
copy of real customer data) — flagged for explicit operator confirmation.
The demo-box backup-target fence drops from prohibition to stated cost,
because D-d spends that reference anyway.

CLAUDE.md gains an End-of-session checklist carrying the STATUS.md
maintenance rule and "a finding goes in OPEN-ITEMS.md first".
2026-08-02 14:20:29 +02:00