Files
felhom.eu/documentation/audits/CAMPAIGN-12-class-sweep-2026-08-08.md
T
admin b7fb2117ae
gates / gates (push) Successful in 20s
CAMPAIGN 12 — the class sweep: golden 0.208.0 baked (awaiting vouch), R-256..R-263 filed, gating ranked
Part 1. Golden 0.208.0 baked on the drill VM, published and ROUND-TRIP VERIFIED — 656 150 362 B,
sha256 ba668f59..5ffb82, and ./etc/felhom-controller-image read OUT of the downloaded archive says
felhom-controller:0.208.0. Acceptance markers all green (overlay2 x1, mount points x2 rootfs+mp0,
upload HTTP 201 x1, excluding/FATAL/mp1 x0), Result=success. Token file->file, read inside the VM;
systemctl show grep = 0; committed-log grep = 0 WITH a control proving the grep works. Bake VM
destroyed, drill disk restored to virgin. NOT VOUCHED — the campaign halts there deliberately.

golden_currency_gate.py was correctly RED on arrival and is green after the bake. No --no-verify
was needed anywhere in this session.

Parts 2-4. Seven defect classes swept for siblings by class rather than by feature. Analysis only:
no product code, nothing deployed, no machine touched beyond the bake VM.

Eight new rows R-256..R-263 (ceiling moved from R-255), grouped by class in OPEN-ITEMS.md. C1
produced no new instance and has no row. The sharpest is R-260: the agent reports
operator_key_configured every heartbeat, the hub has no field for it, so the check that answers
"can the operator get into this box" returns ok for a box with no operator key installed.

Every class states whether its method re-found the known instances, because a method that cannot
re-find them has not been shown to work: C1 2/3 (verified by replaying the pre-fix templates),
C2 2/2, C3 2/3 + 1 as fixed, C4 fix-pattern re-found, C5 re-found, C6 deadcode 0/2 and bespoke 1/2,
C7 weakest and said so. Blind spots stated per class; seven suspicions investigated and DISPROVED,
including two of my own methods.

Part 4's ranking is in ROADMAP.md as G-1..G-8. Gate C5 (cross-repo tag reachability — cheap,
--fast-eligible, would have caught every R-260 instance on the introducing commit). Do NOT gate C6:
golang.org/x/tools/cmd/deadcode was measured against a PLANTED probe and is blind to unreachable
METHODS on widely-used types, which is exactly the shape both known instances have.

R-242's untouched half is recorded, not built: this bake demonstrated it, the currency gate flipping
green the moment the evidence DIRECTORY existed, before the round trip finished and with no vouch
near it.

Correction the campaign owed its own brief: escrow_stale was described as closed; it is R-247 and
READY. The live repo is the source.

Sampled rather than swept, exactly: C7 60 of 2652 production invariant comments and NONE of the 1440
test comments (that half is owed); C2 19 of 221 refusals; C3/C4 controller only. No finding was
reproduced live. STATUS.md is 100 lines against its 93-line one screen.
2026-08-07 23:07:42 +02:00

26 KiB
Raw Blame History

CAMPAIGN 12 — the class sweep (2026-08-08, unattended)

Look for the siblings of everything this arc found, by class rather than by feature. Nothing was fixed, nothing was deployed beyond Part 1's golden, and no machine was touched beyond the bake VM, which was torn down.

Baselines re-read on arrival, all three clean and equal to origin/main:

Component Version commit
felhom-controller released v0.208.0 37b5ba08a704
felhom-agent v0.127.0 703db166e762
hub v0.98.0 deployed 59527d00f9c4
app-catalog-felhom.eu 459766c

Highest register ID on arrival R-255; re-checked before minting, unmoved. This session opens R-256 … R-263.


1. The bake — and the one approval awaiting the operator

Golden 0.208.0 is baked, published and round-trip verified. It is NOT vouched. Full evidence: documentation/tests/golden-0.208.0-2026-08-08/ (README + the 324-line bake.log).

version 0.208.0 (derived from the controller image, which is a required argument)
size 656 150 362 B
sha256 ba668f59f063511dda122a5b05900f1350573cdc9619980872f87a58755ffb82
round trip downloaded back from Gitea, hashed independently on DooPlex — size and sha identical
the delivered artifact's own claim ./etc/felhom-controller-image read out of the downloaded archivegitea.dooplex.hu/admin/felhom-controller:0.208.0
fourth confirmation the sha the hub's /configuration dropdown offers for 0.208.0 matches the bake exactly

Acceptance markers, each grepped verbatim against the real log: docker OK (overlay2; data-root /var/lib/docker) ×1 · including mount point ×2 (rootfs and mp0, no mp1) · upload OK (HTTP 201) ×1 · excluding ×0 · FATAL ×0 · mp1 ×0. Result=success, ExecMainStatus=0.

Hygiene. The token was copied file → file and read from a 0600 file inside the VM by a runner script; systemctl show golden-bake -p Environment -p ExecStart | grep -c -F <token>0. The committed log greps 0 for the literal token, and the grep was shown to work first — the same grep on a copy with the token appended returned 1, then the copy was shredded.

404 pre-gate with a control: …/0.207.0/golden.tar.zst200 (the URL shape is right), …/0.208.0/golden.tar.zst404 (nothing to overwrite).

Teardown: pct destroy 9100 --purge, pct list empty; token/runner/script/log shred -u'd and /root residue clean; poweroff; qemu confirmed gone via ps -eo comm | grep -c qemu-system-x86 → 0 (never pgrep -f, which self-matches); qemu-img snapshot -a virgin restored and the snapshot list re-read.

⚠ THE ONE THING ASKED OF THE OPERATOR

Vouch golden 0.208.0 — hub → Configuration → Day-0 artifacts → Golden 0.208.0 → Save.

field now after why
golden_version 0.207.0 0.208.0 the only field that moves
agent_version 0.127.0 0.127.0 already ≥ MinAgent 0.127.0 — read from the controller CHANGELOG header, checked not skipped
min_agent 0.127.0 0.127.0 equals the declared MinAgent; not above agent_version (the R-216 shape hub v0.97.0 holds)

Vouching is reversible — re-select 0.207.0 and Save. The 0.207.0 package was not deleted (the publish step's pre-delete targets only its own version), so a rollback is a form submission.

Until that Save, a machine installed tonight still receives controller 0.207.0.


2. What golden is vouched, and the currency gate's state

  • Vouched at the time of writing: golden 0.207.0, agent 0.127.0, min_agent 0.127.0, min_controller_version 0.156.0 — read live from the hub, not from a document.
  • golden_currency_gate.py was RED on arrival (controller v0.208.0 is released and NO golden carries it) exactly as §1 of the task predicted, and is GREEN after the bake.
  • No --no-verify was needed anywhere in this session.

3. Per class — new instances, the control, the method, and its blind spots

C1 — a secret in a response body

New instances: 0 on the surface the class names (a response body a customer can fetch).

Control — the shipped gate re-finds 2 of the 3 known instances, measured not assumed. The three pre-fix templates were restored from history (8dbbc98^) into a scratch copy of the controller and scripts/secret_in_markup_gate.py was run against them. It convicted settings_security.html:408, :430 and app_info.html:185, and did not convict deploy.html:482 (value="{{$val}}") — R-255's stated blind spot, now verified by replay rather than by reading its docstring. The gate passes clean over today's 36 templates.

Method (the new half). The shipped gate is name-based on the TEMPLATE side. This sweep is its mirror on the GO side: every assignment into template data, a JSON response map or a render struct whose right-hand side names a secret, regardless of what the key is called — the shape the gate cannot see. 27 sites matched; 5 under a key that does not itself announce a secret.

All 5 resolve to non-findings, and the reasoning is recorded in §6.

Blind spots. One hop only — a secret laundered through a neutrally-named local two or more hops from its source is invisible. Struct-literal page data is covered only in the field-assignment form. A secret fetched inside a helper the handler calls is invisible. And the whole sweep reasons about source, never about rendered bytes — the runtime body assertion remains the only check that catches all three known shapes, and it still covers 4 of 27 pages.

C2 — a refusal that names no route

New instances: 2 (R-256, R-257).

Control. The known instances (R-252, R-253) were re-read in their pre-fix form at 8dbbc98^ and in today's tree. The method distinguishes them correctly: the pre-fix strings state a reason and stop ("nincs mentés felhasználói adattal"), and today's backups_restore.html:79 names the route — „Meghajtók", „Meglévő meghajtó csatolása". Utána gyere vissza ide. 2 of 2 re-found, as fixed.

Method + what was SAMPLED. 221 distinct customer-visible refusal strings exist across the controller's production code. This was NOT swept exhaustively. The sample is the three files on the surface where both known instances lived — recovery_handlers.go, offbox_handlers.go, restore_wizard.go — yielding 19 distinct refusals, all read by hand. The other ~202 strings were not examined.

Blind spots. Refusals composed at render time from template fragments are invisible to a string-literal grep; so are refusals whose text lives in the HTML rather than in Go. "Names a route" is a judgement, not a predicate — two readers could disagree on the borderline cases, and three of the 19 were called borderline and left unfiled.

C3 — a success verdict over an incomplete state

New instances: 1 (R-258). This class was swept COMPLETELY, not sampled.

Method. Every assignment of a success-valued status in the controller's production code: 9 sites, all read. That is the whole population, which is why this is the one class where "no others exist" is a claim the method can support.

Control — 2 of 3 known instances re-found, and the third re-found as fixed. offbox.go:1040 still carries R-240 verbatim: a configured box with nothing selected reports LastStatus = "ok" with the warning „Sikeres — nincs mentésre jelölt alkalmazás". R-234's fix is visible in the same block (missingUnprotectedincomplete). R-203's mandatory-gap fix likewise.

Blind spots. It sees only literal success values assigned to a name matching Status|State|Verdict|Result. A verdict expressed as a boolean, an enum constant, or the absence of an error is invisible — and the controller does use booleans for verdicts elsewhere. It covers the controller only; the hub and agent were not swept for this class.

C4 — an absence reported as a fact

New instances: 1 (R-259).

Control — the fix pattern re-found. R-225's StatsKnown is exemplary and the sweep re-finds it intact: on backups_remote.html every derived figure — size, snapshot count, quota text and the bar itself — is behind {{if .Offbox.StatsKnown}}, with the honest alternative text „a pillanatképek száma még ismeretlen". Its own comment states the principle this class is about: "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a claim."

Method. Enumerate the rendered counts/sizes/percentages in the 36 templates, then ask of each whether a *Known-style companion exists and whether the producing Go call can fail into a zero.

Blind spots. Name-based on the companion flag — a three-state encoding done some other way (a pointer, a sentinel, a separate error field) reads as "unguarded" and needs a human look; two candidates were dismissed that way. Only the controller's templates were covered; the hub's operator UI was not.

C5 — a field one side sends and the other drops

New instances: 1 register row (R-260) carrying a measured census of 8+ dropped fields. This class was the highest-yield, as predicted.

Control — re-found. The known instance is live: the hub's store.EscrowStatus carries Stale bool json:"escrow_stale,omitempty" and the controller's report.EscrowStatus has no matching field. Correction to the task's framing, recorded rather than dropped: this is R-247 and it is READY, not closed. The live repo is the source (rule 7).

Method — and the first one was wrong. Attempt 1 paired structs across repos by json-tag-set overlap. It false-positived badly, because the hub decodes the same controller report through several ad-hoc anonymous structsoffsite.state and offsite.abandon_purge_requested looked dropped and are in fact consumed at api/handler.go:530 and store/store.go:1490. Attempt 2 is the tag-reachability test: for every json tag emitted on a real wire, does that literal string occur anywhere in the receiving repo? A tag that occurs nowhere cannot be decoded by any struct, named or anonymous. That is definitive, and it is the version that generalises into a gate.

Blind spots, several of them earned during the sweep. (a) The emit sets initially included decode structs — hostReportPayload, applianceRegisterReq, WireDesiredState — producing nonsense in the reverse direction; those rows were struck. (b) The struct extractor flattens anonymous nested structs, which produced a phantom asymmetry on hostStorageTarget (§6). (c) 29 generic tag names (name, type, state, status, …) were excluded by name because a repo-wide string test is meaningless for them — a genuine drop of a generically-named field would be missed. (d) It cannot tell a deliberate ignore from an accidental one; the hub's hostReportPayload documents that it ignores several collections on purpose, and those were not counted.

C6 — built and never wired

New instances: 1 (R-261). The more valuable output is the method result, not the list.

Control — and the standard tool FAILS it. golang.org/x/tools/cmd/deadcode was run against all three binaries and re-found neither known instance. A planted probe measured why: an unreachable exported function added to the hub's store package is reported; an unreachable exported method on *Store, in the same file, is not. RTA treats the method set of a dynamically-used type as potentially reachable. Both known instances are methods(*Store).MarkEscrowStale and (*RestoreTestState).Snapshot, both confirmed still callerless today — which is exactly the shape it cannot see.

A method-aware textual sweep was then written. It re-finds 1 of 2 (MarkEscrowStale); it misses RestoreTestState.Snapshot because a .Snapshot() call on a different type in guestnet/report.go satisfies its name-based test. So: 1 of 2, and stated as 1 of 2.

Findings after triage: small, and mostly inert — which is itself the result. 137 raw candidates → 31 whose doc comment claims a consumer → after removing method-values-as-callbacks (PBSDRAutoProvision), cross-repo name reuse (DebugLogs, AppStopGuard) and template dispatch, five genuinely have only their own declaration. Four of those five are inert: the data reaches its stated consumer by another route. OffboxOrphanedRenamedTo says "for the card copy" and has no caller — but R-228 is genuinely closed, because the card reads the settings field .Offbox.OrphanedRenamedTo directly in backups_remote.html:80. Same for OffboxEscrowState. An unwired accessor whose data reaches its consumer another way is dead code, not a defect, and ranking it as one would inflate this class.

The one that is not inert is R-261.

Blind spots. Go templates call methods with no .Name( syntax; method values passed as callbacks likewise; reflection and struct-literal function fields likewise. Same-name methods on different types are conflated, which makes the sweep conservative — it under-reports and never over-reports. A method used only inside its own file counts as used, so whole-subsystem orphans are invisible.

C7 — a comment asserting an invariant the code does not hold

New instances: 2 (R-262, R-263), both found while sweeping other classes.

Method + what was SAMPLED — this is the class where the honesty matters most. There are 4092 comments across the three repos matching the invariant vocabulary (never / always / must not / cannot / is the only / guarantees / impossible) — 2652 in production code and 1440 in tests. Exhaustive is not possible in one session and was not attempted. The sample is the 60 production comments containing "is the only", chosen because a uniqueness claim is the one form that is mechanically falsifiable by a grep. Of those 60, the safety-bearing ones were tested by hand. The other ~2592 production comments and all 1440 test comments were not examined — and the task's own instruction to check the tests' claims is therefore only partly discharged: the two instances below are production comments.

Control. The nine known instances are the repo's own table (in the felhom-testing skill) and are closed; the sample re-finds the pattern rather than the instances, so C7 has the weakest control of the seven and should be read that way.

Both new instances are stated in the register. The sharper one, R-262, is the campaign in miniature: a comment claiming a contract is mirrored field-for-field and guarded by a key-set test, where the contract is two fields short and the fixture the test reads omits the same two fields — the instrument is blind in exactly the place the defect is.


4. Which classes can be gated — the ranking

Ranked by what a gate would be worth, using this campaign's own instance counts as the evidence.

rank class gateable? cost what it would MISS
1 C5 Yes — cheaply and definitively ~150 lines of Python, no network, --fast-eligible generically-named tags; a deliberate ignore looks like an accident, so it needs an allowlist-with-reasons like the C1 gate has
2 C3 Yes small — the whole population is 9 sites verdicts expressed as booleans or as the absence of an error
3 C4 Partly medium the companion-flag convention must be adopted deliberately; a gate can enforce "every rendered figure has a *Known" only once that is the house style
4 C1 Already gated, partly completing it is the expensive one see below
5 C7 Narrowly small for the narrow form only uniqueness claims are mechanical; the other ~97% of the vocabulary is not
6 C2 No, not mechanically "names a route" is a judgement; a gate could only enforce a convention
7 C6 No — and this is the finding the off-the-shelf tool is blind to the project's own shape, measured

C5 is the clear first. The tag-reachability test is a string comparison over two repos' source: it convicts definitively (a tag occurring nowhere in the receiver cannot be decoded), needs no network, and would have caught the escrow_stale instance, the operator_key_configured instance and every other row in R-260's census on the commit that introduced them. It is the cheapest high-value gate available to this project. Its one design requirement is an allowlist with stated reasons, because the hub deliberately ignores some collections.

C6 does not hold, and saying so is the point. The class looks the most mechanical of the seven and it is the one where the standard tool measurably fails. Any gate here would have to be written from scratch, would be conservative by construction, and — on this campaign's evidence — would spend most of its output on inert dead accessors. Recommendation: do not gate C6. Gate C5 instead, which catches a strict subset of the same "the answer was available and discarded" family with none of the ambiguity.

C1's existing gate, stated honestly. secret_in_markup_gate.py covers all 36 templates on the name-based check and is blind to the neutral-page-data-key shape — measured this session by replay, not merely quoted: it convicts 2 of the 3 known instances and not the third. The runtime body assertion, which catches all three, covers 4 of 27 pages. Completing it means constructing each remaining page's data in a test — the cost is per-page and real, and it is the honest reason it has not been done. The Go-side mirror written for this campaign is a cheap partial third net and is not recommended as a gate on its own: it produced 27 candidates and 0 findings, which is a bad signal-to-noise ratio to put in front of every push.

Nothing was built. These are candidates recorded in backlog/ROADMAP.md.


5. Harness faults, separated from product faults

Harness, not product:

  1. My first C5 method was wrong and would have produced ~10 false findings had it been believed — struct-pair diffing cannot see a receiver that decodes through ad-hoc anonymous structs. Replaced, not patched over.
  2. My struct extractor flattens anonymous nested structs, which manufactured a phantom hostStorageTarget asymmetry (§6).
  3. A grep -v host_oob.go excluded monitor/host_oob.go too, and I briefly believed GetHostOOBStates had no caller. Caught by checking before writing it down. Excluding by basename is a trap worth naming.
  4. deadcode failing its control is a harness fault in the sense that matters here — but it is reported as a result in §3/C6, because "the standard tool cannot see this project's shape" is exactly what Part 4 was asked to establish.

Session hygiene, recorded: this session did not run inside tmux, contrary to the workspace CLAUDE.md. Nothing was lost, but the whole unattended run was one dropped connection from dying.


6. Suspicions investigated and DISPROVED

A campaign reporting only what it found is half a campaign.

  1. offsite.state / offsite.abandon_purge_requested are dropped by the hub. DISPROVED. The hub's monitor.offsiteReport has neither, but both are consumed through separate ad-hoc structs — api/handler.go:530 and store/store.go:1490. This is what killed method 1.
  2. A restore-test mount-parity mismatch is invisible to the hub. DISPROVED, and it mattered. mount_parity and mount_inventory genuinely are dropped, but a mismatch sets res.Err and returns before res.Pass = true (reconcile/restoretest.go:277-283), so the verdict travels as pass:false with the delta in the error string. What is lost is the depth of a pass, not the failure. R-260 states it that way rather than the way it first looked.
  3. hostStorageTarget does not mirror the agent field-for-field. DISPROVED. The 11 "missing" SMART fields are nested under smart on both sides; my extractor flattened the hub's anonymous inner struct. That half of the comment is TRUE — which is what makes the hostRestoreTest half (R-262) a real finding rather than a sweeping accusation.
  4. GetHostOOBStates has no caller (a C6 instance). DISPROVEDmonitor/host_oob.go:40,77. My own grep excluded it.
  5. Validation.TableCount reports 0 tables over a validation that could not run (a C4 instance). DISPROVED. backups_apps.html:74-82 is correctly three-stated: Valid → the count, Error„Hiba", neither → „Az érvényesítés nem futott le". A good example of the class handled well.
  6. The controller leaks a NAS password into a response body (a C1 instance). DISPROVED. netstorage_handlers.go:163 puts it in a request to the agent, documented in place as never persisted and never in the job status.
  7. OffboxOrphanedRenamedTo being callerless means R-228 regressed. DISPROVED — the card reads the settings field directly (backups_remote.html:80). R-228 is genuinely closed.

7. What was SAMPLED rather than swept, exactly

class swept completely sampled NOT examined
C1 all 36 templates (shipped gate); 27 Go-side candidate sites the 23 pages with no runtime body assertion
C2 3 files / 19 refusals on the recovery-restore-offbox surface ~202 of 221 refusal strings; all refusals composed in HTML
C3 yes — all 9 success-status sites in the controller the hub and the agent entirely; boolean/enum verdicts
C4 the 36 controller templates' rendered figures the hub operator UI
C5 yes — all 4 wire directions, 465 emitted tags 29 generically-named tags, excluded by name
C6 all 3 binaries via deadcode; 1323 exported methods textually 31 of 137 candidates triaged by hand the 106 candidates whose doc comment claims no consumer
C7 60 of 2652 production invariant comments ("is the only" only) ~2592 production + all 1440 test comments

8. R-242's untouched half — what would catch a skipped vouch earliest

Recorded, not built. This bake demonstrated the gap rather than arguing it: golden_currency_gate.py flipped from red to green the moment the evidence DIRECTORY existed — before the round-trip download had finished, and with no vouch anywhere near it. Its positive signal is "a directory named after the version is present in the repo".

Earliest catch, in order of how early it fires:

  1. Earliest and cheapest — make the vouch the thing that writes the evidence. The gate cannot read the hub (its docstring records why: a network-reading gate is not --fast, so it would run in neither the pre-push hook nor CI). But the bake session can: after the operator saves, re-read /configuration and write the observed golden_version into the evidence README as a machine- readable line. The gate then requires that line, not merely the directory. It still cannot detect a lie, but it detects the forgetting, which is the actual failure mode the gate was built for.
  2. Loudest — a hub-side check, not a repo-side one. The hub already knows both numbers: the vouched golden_version and the newest controller the fleet reports. A daily monitor raising golden_behind_release would catch a skipped vouch and a vouch that silently rolled back, which nothing in git can ever see. It fires within a day rather than at the next push.
  3. Weakest — a checklist item. R-242 already was a rule without a mechanism, and it recurred the next day. Recording it again is not a fix.

Recommendation: (1) now, (2) when the hub is next touched. (1) closes the recurrence that has cost two releases in two days; (2) closes the class.


9. Register rows opened, grouped by class

Highest ID moved: R-255 → R-263.

class rows
C1 none — no new instance
C2 R-256, R-257
C3 R-258
C4 R-259
C5 R-260
C6 R-261
C7 R-262, R-263

Gating candidates from Part 4 are items in backlog/ROADMAP.md, not register rows.


10. What did not run, and why

  • The vouch. Deliberate — §3 of the task halts for it, and the campaign does not depend on it.
  • Any fix. Deliberate — §0 forbids it; a campaign that fixes as it goes measures a moving target.
  • C7 exhaustively. Not possible in one session: 4092 comments. Sampled and declared.
  • C2 exhaustively. 221 refusals; 19 read. Sampled and declared.
  • C3/C4 on the hub and agent. Only the controller was swept. Not attempted, not a claim.
  • The tests' invariant claims (C7's second half). 1440 test comments; none examined. The task asked for this explicitly and it is owed, not done.
  • Live validation of any finding. Every finding in this report is from source reading. None was reproduced on a live box — deliberately, since §6 rule 2 permits no machine beyond the bake VM. R-258 and R-259 are the two most worth confirming live before they are fixed.

11. Observations — noticed and NOT acted on

  1. The felhom-golden package list holds 14 goldens back to 0.136.0, none ever pruned. Not a problem today (a rollback needs the old package to exist), but nothing decides when one may go.
  2. The hub's host-report.golden.json fixture contains cpu_temp_c and loadavg, which no hub struct decodes. A fixture carrying keys the receiver cannot read is a quiet way for a "contract test" to look broader than it is — the same shape as R-262, one level down.
  3. readDiskUsage (R-259's site) is one of several collectors that return silently on error, leaving the caller's zero value. The class may be wider than the one rendered figure this campaign measured; only the dashboard's system-disk meter was traced to a customer-visible surface.
  4. The re-enroll response echoes new_api_key back to the caller that sent it (api/dr.go:148). Not a leak — the box already holds it — but it puts a live credential in any transcript of that response, and the endpoint is one an operator drives by hand.
  5. The hub operator UI renders RetrievalPassword into page data (web/configs.go:672, :1262). Outside C1 as defined (operator-only, not a customer-fetchable page), so not filed — but it is the R-249 shape on the other surface, and the operator UI has no equivalent of the controller's gate.
  6. This session did not run inside tmux, contrary to the workspace CLAUDE.md.