The day-0 race (DIAG-f10): a fresh box boots below the operator floor, the
apply-bridge consumes the single-use offsite password, then ~35s later the
managed auto-floor update replaces the container mid-install -> the new process
finds no installed key -> consume -> 404 -> offsite dead until an operator
Re-issue. Recurs on every onboarding whose ISO floor lags the managed floor.
Ordering-only fix (consume/install/persist internals + the 404-no-oracle
contract + the Consumer UNTOUCHED; R-71(b) rejected-by-design):
- New seam offsiteapply.SettleProvider.SettleState() + SettleFunc adapter over
the self-updater's own GetFloor()/IsUpdateRunning() (no second floor path).
- Bridge.AwaitSettle polls 10s BEFORE the 3-min Reconcile ctx: defers while an
update runs or the box is below the known floor; GOes at/above floor on the
first poll with zero added latency (B'). Bounds 90s floor sub-bound / 5min
overall, both GO+WARN (hub that can't serve a floor can't serve a consume ->
no burn risk; R-71c is the belt). ReconcileWhenSettled = gate then reconcile.
- main.go: bridge goroutine moved after the updater is built; wired only when an
updater exists (nil Settle = reconcile immediately, old behavior).
Finding: the floor is in-memory (report-ACK ~5-10s), NOT persisted -> unknown on
any restart until the first ACK; the 90s sub-bound is sized to that.
Tests (injectable clock, fake SettleState, recorded Consumer): A-E + nil-provider
+ cancelled-gate. Four red-proofs all observed FAIL then restored: gate removed /
updateRunning branch / floor sub-bound / overall bound. Deferral paths ship
unit-proven + red-proofed, NOT live-fired -- their precondition is now
structurally prevented by the v1.25.0 build gate. Layering: gate prevents, (a)
defers, (c) heals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Drmtm2RzoqbkJZCNSFNQ
Network shares bind their share ROOT :rslave into FileBrowser — no
skeleton, no userdata scoping, nothing written toward the NAS. Gate is
the stub classifier (stub ⇒ excluded from mounts AND sources — an
exposed stub swallows uploads the real mount later shadows); idle autofs
is healthy and included. Drives byte-identical. Add/remove trigger the
debounced sync. Phase-0 probe on demo-hp: GO (in-container rslave access
wakes the idle trigger). Red-proofs A + B run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
Leg A: „Hálózat" card on Beállítások → Rendszer — Helyi cím (LAN),
Hálózati név (only while Megosztás is enabled), Átjáró; live per render,
stored nowhere (S-5), „—" on unavailable.
Leg B: network section in the Debug system dump (interfaces/route/DNS/
lan_address), best-effort per item via the samba-netns door.
Leg C: NetBIOS trap named — Szerver field helper text + a purely lexical
hint on unreachable failures for single-label non-IP names.
Design note: all guest-net reads go through docker exec into the
host-networked felhom-samba container (stacks/guestnet.go, one seam) —
the controller's own netns is the docker bridge, so /proc/net/route etc.
would answer 172.x (the S-2 trap). Red-proofs: A2 gate-drop and C2
lexical-invert both failed as required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
Shipped in v0.158.0, caught live within the hour. /apps/<slug> returned 500
for EVERY app: html/template cannot call a pointer-receiver method on a
non-addressable value, and appDetailHandler passes Meta as a VALUE inside a
map[string]interface{}.
It compiled and every test passed because nothing rendered app_info - the
catalog tests used the funcmap route, which takes a value and works either
way. A template method call is only checked when the template runs.
Adds TestAppInfoRendersForEveryLifecycle with the handler's exact data shape.
The previous commit landed only the new test/badge files: a 'git stash' used
to compare REUSE.md ref-check output silently dropped the staged index, so
every modification to an existing file was left behind and that commit does
not build. This adds the metadata field, the predicates, the fail-closed
deploy gate, the catalog filter, the funcmap entries, the template edits and
the docs that those tests exercise.
The catalog knew only 'present' or 'gone', and 'gone' orphans every customer
already running the app. lifecycle: in .felhom.yml withdraws an app from new
installs without touching anyone running it.
Deploy gate is server-side and fail-closed, before any mutation, with the
ruled Hungarian refusal - hiding a button is not a gate. Unknown values fail
OPEN (available + one WARN), deliberately opposite, so a typo or a newer
catalog cannot pull a working app out of every customer's list.
Orphan detection never sees the field - a red-proof adds that filter and shows
the abandoned app immediately reading as an orphan.
Badge plumbing is generic (MetaBadge + meta_badge partial) so R-56's
difficulty labels drop in with no new markup.
shouldRecreateOnBoot keyed on Deployed+drive-present alone. Deployed stays
true across a Stop, so a drive-backed app the customer switched off was
silently restarted on every guest reboot (proven live: immich).
Requires len(Containers)>0 as well - R-52's existing-Exited vs absent
distinction. A UI Stop is compose down and removes the containers; a guest
that went down under a running app leaves them. Container STATE is still
deliberately NOT a filter: that would miss a not-yet-restarted or stuck-Exited
app, which is the bug the boot-id path exists to fix.
Evidence sampled before any recreate - recreate's own StopStack erases it.
Honoured Stops counted and logged separately from no-live-bind skips.
R-51: aggregateState's mixed branch returned StateRunning ("partial"), so a stack whose
MAIN container was dead behind live helpers alerted on nothing — immich-server sat Exited
for 18 h, 100 % unreachable, no banner and no app_start_failed (audit F4). New
StateDegraded: a DOWN member whose docker restart policy is always/unless-stopped is a
fault (degraded, a down state); no/on-failure is a finished one-shot and stays benign; an
unreadable policy fails CLOSED. The unhealthy/restarting/paused/unknown exclusions are
byte-identical — folding unhealthy into down is the flapping fix-3 avoided.
R-52: new internal/bootrecon — one bounded start-once sweep at startup (2 attempts, 30 s
apart) for apps an interrupted boot left behind, inside the 90 s boot grace so a success
is silent and a failure still alerts. A zero-container stack is NEVER touched: the UI's
Stop is compose down, so a deliberate stop survives a reboot.
Both features carry a production-path wiring test (the v0.154.0 / v0.91.0 inert-seam
class). The main() assertion is an AST walk, not strings.Contains — the substring version
passed its own red-proof, because a commented-out call still contains the string.
Red-proofs run and restored: mix branch reverted -> "running" on the immich fixture;
boot hook commented out -> wiring test fails; zero-container gate dropped -> the
user-stopped app gets started.
NOTE: controller/cmd/controller/ is matched by .gitignore's `controller` entry, so new
files there need `git add -f` (and ripgrep silently skips main.go without --no-ignore).
Fixes a defect shipped in v0.154.0, found by the operator on the first live
click-through of the new wizard.
backup.Manager carries TWO running booleans. `running` (read by IsRunning) is the
concurrency single-flight, acquired inside the background goroutine — and
RestoreOffboxScratch never acquires it at all. `opRunning` (read by RestoreStatus) is the
display flag, set synchronously by BeginRestoreOp in the handler.
The wizard sourced OpRunning from IsRunning(), so for „Ellenőrzés" and the full-restore
preparation — its two most-used and longest actions, both streaming from restic — the
execution step was unreachable: the page offered all three intents with live buttons
while a restore was running, and the progress banner contradicted the phase strip on the
same screen. Pressing anything there would have been refused by the handler, which is the
exact "offering a control guaranteed to fail" dishonesty R-48 exists to remove.
Fix: restoreOpInFlight(st) behind a documented seam, fed by a SINGLE RestoreStatus() read
per render so the strip, the suppression decision and the running-op name cannot diverge.
Why the tests missed it: the Scenario-E table proved deriveWizardStep behaves correctly
GIVEN OpRunning=true, but nothing proved the handler ever computes true — hollow at
exactly that seam. TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag now drives a
real Manager through BeginRestoreOp and asserts the render suppresses every form.
Red-proofed against the v0.154.0 shape.
Also: „Eredmény" was a dead label. The strip's highlight is now its own derived Phase,
separate from Step — a finished restore returns to the intent step (everything available
again) while the strip reads „Eredmény" and an outcome card shows the result. Bounded by
restoreResultWindow (10 min) so a stale result cannot look fresh, and bound to the app so
a finished bookstack restore does not light immich's page with bookstack's message. The
card survives a reload; the redirect flash does not.
No new agent coupling — MinAgent stays 0.90.0.
The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).
The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.
Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.
deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.
No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.
Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.
No new agent coupling — MinAgent stays 0.90.0.
Closes R-47. No new agent coupling — MinAgent stays 0.90.0.
The replay needs a running DB container, so both restore paths started the
WHOLE stack first, giving the application a window to rebuild the very schema
objects the dump was about to create. Measured live on 2026-07-19 (H4,
DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds
before the dump's CREATE INDEX, the replay aborted "already exists" under
ON_ERROR_STOP=1, and immich reported schema drift. The data survived only
because pg_dump emits COPY before CREATE INDEX.
Both paths now open a DB-ONLY window: only the stack's database service(s)
come up, the dump is replayed with the app still down, and the full start
runs only after the replay exits 0. Fail-closed: a dump with no identifiable
DB service refuses BEFORE the first mutation. Every exit from the window
still does a best-effort full start, so a failed restore never leaves a box
with a database and no application.
New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line
scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage
with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty
list — argument-less `up -d` is a full start); RedeployFromEnv split into
PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's
RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden
`up -d` inside the old name is what carried the defect on the local path.
19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed
effects, replay-failure bring-up, parser decoys, empty-list refusal); three
companion red-proofs run and reverted. 23/23 packages green.
Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
The fab-upload GC test stat-ed the .part immediately after observing the slot
free, but expireIdleUpload unlinks AFTER releasing the mutex. Passed alone,
failed in the full package once this release's render tests made web heavier.
Not a production defect - a new upload mints a fresh random .part. The test now
waits for the outcome it asserts on the same deadline; red-proofed by removing
the unlink from production.
Capture on the box disproved the first theory: macOS DOES send a correct NBNS
query for <NAME><20> and nmbd DOES answer it correctly in 140us (flags 0x8580,
RCODE=0, right address) - macOS just never acts on it. NetBIOS there feeds
legacy browsing, not smb:// URL resolution, so the bare name can never work on
a Mac and nmbd was never the broken part.
felhom-samba 1.1.0 adds avahi + dbus, with avahi-daemon.conf and the _smb._tcp
service file templated from FELHOM_SERVER_NAME so a rename re-advertises. Both
daemons are non-fatal on failure - a discovery gap must not become an outage.
v0.151.0's card offered smb://<NAME> for Mac, which is exactly the dead form;
now smb://<NAME>.local. Windows keeps the flat \\<NAME>, which nmbd serves
correctly. Red-proofed both directions.
NOT claimed: Finder-sidebar discovery - published and answering on the wire,
but not observed working on the test Mac. Recorded OPEN.
TestRenderSambaCompose pinned the literal 1.0.0 tag, so an image bump read as a
renderer regression; now derives from SambaImage and asserts non-:latest.
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first
poll of every steady-state page load reported a terminal job that never ran and
the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty
(liveness must never be contradicted) belongs to the 'running' LEVEL field
beside it, and is now pinned by its own regression test.
S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot
re-arm the reload on the page it just caused. failed/needs_password/in-flight
are never consumed. Unified async-job feedback stays the ROADMAP item.
S-2/S-5: new connect card with the Windows form, the Mac form and the direct
smb://<IP>, read from the SAMBA container's netns (the controller is on a docker
bridge and would answer 172.x). Derived per render, cached nowhere - the address
is a DHCP lease. Underivable => the line is omitted.
sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three
ways. 23/23 packages green.
F7/R-53: app_export.html built the app's public URL as '<sub>.{{$.CSRFToken}}',
so the "Megnyitás" link was wrong for every app with a subdomain and a session
CSRF token was written into a URL. Template now uses {{$.Domain}}, and
exportPageHandler supplies the key — it builds its own data map instead of
going through baseData, which is where every other page gets it. The page's
real CSRF path (csrfH() reading the meta tag) is correct and untouched.
The 7 red internal/backup tests are green again, with no behaviour change.
TestTier2V2_* / TestSharesTier2* all failed for one environmental reason:
Tier-2's off-drive guard asks system.SamePhysicalDevice (st_dev equality)
whether a target is really a second disk, and every t.TempDir() here shares one
filesystem — so the guard correctly refused the fixture's "two drives" and the
tests never reached their subject ("nincs másik fizikai meghajtó").
Seam in the package's existing style: a nil-defaulted Manager.samePhysicalDevice
field + sameDevice wrapper, seven call sites routed through it. Nil resolves to
system.SamePhysicalDevice, so production is byte-for-byte unchanged; only the two
fixtures inject a fake modelling one drive per directory subtree. No assertion
weakened, nothing skipped/renamed/deleted; all 7 mutation-proved.
Also: the ssh->pct-exec ASCII-grep and heredoc-credential traps are now in
CLAUDE.md's live-validation section.
The card claimed "Utolsó mentés: Még nem futott" on every box, forever, even
with dumps on disk and db_dump_completed events in the hub. dashboard.html
branches on {{if .BackupStatus}} and reads .Success/.LastRun, but
dashboardHandler never set that key — so the {{if}} arm was unreachable and
the "never ran" else-branch rendered unconditionally. The neighbouring
"Adatbázisok: N mentve" row kept working because it reads DBDumpStatus, which
was passed; that is the contradiction the audit caught on the live box.
Fix is the one-line pass-through the template always expected:
data["BackupStatus"] = fullStatus.LastDBDump. *DBDumpStatus nil/non-nil maps
onto the template's branch, so a fresh box still reads "Még nem futott" and no
zero-value timestamp is fabricated. No template change, no new view-model.
Tests drive the real handler through ServeHTTP so they bite on the handler
wiring, not the template alone. Red-proofed: deleting the assignment fails
TestDashboardBackupCard_ShowsLastRun.
Origin: felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md (F3)
Closes the two findings from DIAG-immich-restore-2026-07-19. Viktor deleted 11
immich photos to test offsite restore; both runs flashed success and the photos
stayed gone. Two independent defects.
R-43 — no offsite path could restore a database. All three buttons were
file-only: the two "visszaállítás" actions staged to a scratch folder and never
touched postgres, and place-to-live merged only MISSING files. For a DB-indexed
app the bytes returned and the app still could not see them. The dump was
carried INTO every snapshot and could never be replayed OUT of one.
New ReconstituteFromOffsite (/backup/offbox/reconstitute): safety dump → stop →
files overwritten to the snapshot version → start → the snapshot's own dump
replayed → health wait. Two invariants:
- nothing is ever deleted (-a, no --ignore-existing, no --delete): a file
created after the snapshot survives as an extra;
- the undo exists before the act — the pre-restore- dump is verified ON DISK
before anything is stopped, overwritten or replayed; if it cannot be taken
the operation refuses with zero changes.
The replay reads the SCRATCH unit: the live unit is never overwritten, so
replaying from it would replay the current DB over itself and restore nothing.
R-44 — a manual push shipped an unrefreshed dump (up to ~24h old). That day's
predated the customer's account by four hours and probed to asset:0/user:0/
album:0 inside 52MB whose bulk was immich's shipped geodata. Every run, manual
AND nightly, now refreshes dumps + units BEFORE capturing. Order is the
mechanism: the gap can only ADD files the DB does not reference yet, never
remove one it does. Manifests carry offsite_run_id + dumps_at, so coherence is
verifiable at restore time rather than assumed; the periodic refresh carries a
prior stamp forward and never invents one.
Honesty surfaces, all warn-level and none a gate: unstamped (pre-v0.148) pairs
report their skew, ValidateDump gained an EXACT-match accounts-table sniff for
customer-empty dumps, the completion flash states an outcome instead of a
mechanism, and the missing-only button now says what it does NOT do.
11 tests; 5 red-proofs run and reverted. Two of those found real test weaknesses
rather than confirming strength — the first undo mutation was caught by a second
guard, and the first table-matching test did not discriminate between the two
matchers at all. Both tests were rewritten to the cases that separate them.
NOT in scope: R-41's catalog invariant check, nightly cadence, retention, quota
math, tier-2, and v0.147.x progress semantics beyond one added phase line.
Live acceptance (§9) has NOT run: no capability-map flip, customer-restore row
stays MISSING, R-3 stays DRAFT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Nn14TWGzKoqAJAiVwC2s
Third real run, third thing only a live run could show. The per-app legs
finished in ~15 seconds; the remaining 40 of the 57-second run was the shares leg
and forget --prune, during which the card sat frozen on "calibre-web — 8 / 8
fájl". The same frozen-looking silence 4c exists to remove, relocated to the end.
Progress now carries a phase. The post-app stages announce themselves and the
app-scoped counters are cleared when a phase starts, so the last app's finished
numbers are never shown against work that is no longer about that app. Starting
the next app clears the phase again. Pinned by a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
The v0.147.1 file-count fallback fixed the incremental case but not the one the
demo box actually hits. Watching a second real run: bookstack reported clean byte
progress (100%, 154.0 MB, 7/7 files — the byte path works), while immich sat at
files_done 1 of 46, bytes_done 0, for 42 seconds.
restic 0.14 only counts a file into bytes_done/files_done when it COMPLETES, so
an app dominated by a single large archive (immich's ~430MB volume tar) freezes
both counters. No percentage can move in that window, so stop trying to fake one.
restic keeps reporting current_files and seconds_elapsed throughout. The card now
names the file being processed and the elapsed time: "1 / 46 fájl (430.2 MB) ·
feldolgozás alatt: immich_upload.tar · 42 mp". "Working on this file for 42
seconds" is a completely different message from "0%", and it is the honest one.
The last known current_files value persists across ticks that omit it (restic
does not send it every tick, and blanking the label every other second is its own
flicker); switching app clears it so one app's file is never shown against
another. Both pinned by tests, with the real 42-second status line shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
Found by watching the v0.147.0 card during a real manual run on the demo box,
which is the only way this was going to surface: a 430MB immich push reported 0%
for 40+ seconds and then completed.
The parser was not broken. restic was genuinely reporting no transferred bytes —
on an incremental run where nothing changed, bytes_done is omitempty on restic's
side so it is not even in the JSON, and percent_done stays 0 for the whole run.
Confirmed against the real schema by capturing backup --dry-run --json from
restic 0.14.0 in the controller image rather than guessing; those captured lines
are now quoted verbatim in the type's doc comment.
Why it mattered: a byte-only bar is indistinguishable from a hang in the COMMON
case, which is precisely the silence 4c set out to remove. Shipping it would have
traded "no feedback" for "feedback that says 0% and looks stuck".
files_done/total_files are now parsed and published alongside the bytes; the card
prefers bytes when bytes move, otherwise drives the bar from files and says
"N / M fájl ellenőrizve". parseResticStatus returns a struct instead of four
positional values, and a new test pins the real incremental line shape (bytes
absent, files climbing) so a refactor cannot quietly restore the stuck bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
The systemic complaint, twice in one evening: you press a button and nothing
happens. No progress, no ETA, no named result. Three worst offenders, fixed on
the two patterns already here (deploy 3-step panel, storage-init status poll).
No new framework — that is a ROADMAP item; three targeted cards ship tonight.
4a — a verification restore names its result. The flash said the app had been
restored "to a verification folder on the drive"; which folder, on which drive,
was invisible, so the customer could not go and look at what they had just asked
for. Full path now. The restore page gained a listing of existing verification
copies (app, size, date, path) — nothing anywhere showed these, so they piled up
and the only way to find them was SSH — each with a double-confirmed delete.
That delete is the only one this release adds, so it names a STACK, never a
path: the Manager resolves the name inside a backups/offsite-restore root it
computed itself and refuses anything landing outside. Red-proofed — neutralise
the name guard and stack:"" resolves to the offsite-restore ROOT and takes every
copy with it. Refusals are asserted as non-effects.
4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba
synchronously inside the POST handler; on a golden without felhom-samba baked
that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post
followed by "Beállítás mentve." whether or not anything came up. Detached +
polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE
the work starts, since afterwards the image is always present. Success is
probed, not inferred (compose up -d exits 0 on a crash-loop). The password form
starts the same job: with UserSet false reconcile deploys nothing, so on a fresh
box that is where the pull actually happens.
4c — "Távoli mentés most" streams real progress. restic was already reporting
bytes and percent; the runner seam used CombinedOutput() and discarded them. The
manual run now passes --json and scans stdout line-by-line: total bytes, percent,
current app. Manual only — the nightly stays silent, pinned by a test that fails
if it ever passes --json. The poll now arms unconditionally, closing a race the
manual trigger always ran: the redirect rendered before the goroutine wrote
LastStatus=running, so the poll never armed and the page sat static during the
very run just started. Red-proofed twice.
Also closes the golden/controller infra-image drift at the source: infra.Images()
derives from the existing pins and --print-infra-images exposes it, so the golden
bake can stop carrying its own copy. That copy had already drifted — felhom-samba
was never added, so the golden baked 3 of 4, which is why enabling Megosztás
pulled at runtime in the first place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
The collapsible sidebar is a progressive enhancement: the server marks the group
owning the active page with .is-open, so the correct group is open before any JS
runs and stays open if JS never runs. That server-side half is what these tests
pin — the part a browser screenshot confirms only for whichever page happened to
be open when the screenshot was taken.
Four properties, rendered through the real shared layout rather than a hand-built
fragment:
- every sub-page opens its own group, with aria-expanded=true and an .active
toggle, and EXACTLY ONE group open (the count is asserted, not just the group
we expected);
- a page outside any group (dashboard) opens nothing;
- each group's landing page still exists as a sub-link — the property that made
converting the headers from <a> to <button> safe. If someone drops one of
those sub-links the destination becomes unreachable from the sidebar
SILENTLY, because the header still looks clickable;
- the toggle is a real <button> and its aria-controls targets an element that
actually exists.
Red-proofed: removing `{{if $storageOpen}} is-open{{end}}` from layout.html
fails both the open-group assertion and the exactly-one-open count on the two
storage pages, then passes again when restored.
Note on verification: the live authenticated render could NOT be checked from
here — the demo controller's password is customer-owned since the claim flow
(Viktor set it during the 2026-07-18 rehearsal), so the credentials on the build
server are stale and a curl-login returns the Bejelentkezés page. These render
tests exercise the same template through the same loadTemplates() path the server
uses; the visual leg needs Viktor's browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
UI-only; no behavioural or backup/restore surface touched. Green: build, vet,
tests all pass; template_id_gate, emoji_gate, native_confirm_gate,
offbox_rename_gate, mojibake_gate, app_row_dedup_gate all PASS.
Scrollbars (style.css): the platform default reads as a bright chunky stripe
against the navy and competes with the content it scrolls. Now thin and
hairline-coloured — scrollbar-width/scrollbar-color for Firefox AND
::-webkit-scrollbar (8px, thumb --line, hover --text-3, --radius) for
WebKit/Blink, because neither alone covers the browsers customers use. The two
surfaces that actually scroll take their own panel background as the track
(.sidebar -> --bg-2, html -> --bg-0) so the gutter never shows as a lighter
channel. Tokens only, no raw hexes.
Collapsible nav groups (layout.html + style.css, vanilla JS, no framework):
Tárhely, Biztonsági mentés and Megosztás become accordions with a chevron;
exactly one open at a time, clicking the open one closes it. Groups without
sub-items are untouched plain links. Hungarian labels unchanged.
- The header is a real <button>, so keyboard and AT reachability come for free
instead of being simulated with tabindex/role on a div. aria-expanded +
aria-controls + a :focus-visible outline.
- Nothing became unreachable when the header stopped being a link: every
group's landing page is ALSO its first sub-item (/storage -> Meghajtók,
/backups -> Áttekintés, /sharing -> Hálózati megosztás). Checked before the
conversion, not assumed.
- Progressive enhancement: the group containing the active page is rendered
open SERVER-SIDE (.is-open), so the right group is open before any JS runs
and stays open if JS never does. The listener only handles clicks.
- No layout jump: collapse animates grid-template-rows 0fr -> 1fr (with
min-height:0 + overflow:hidden on the sub-list) rather than max-height, so
it animates to the content's REAL height and there is no magic number to
drift when a group gains or loses an item — the specific way a max-height
accordion rots. The toggle reserves its 3px active border as transparent so
becoming active adds no width shift. .18s transitions, and both the collapse
and the chevron rotation are disabled under prefers-reduced-motion.
Pre-existing and deliberately NOT bundled: docker_run_volume_path_gate.py still
fails on internal/appexport/estimate.go:179. That is ROADMAP R-29, unrelated to
this change, verified to fail identically on the untouched tree, and R-29 itself
says not to bundle it into an unrelated feature commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
Found by LIVE VALIDATION, not by a unit test: the first demo tier-2 run pushed
'Masodlagos mentes elkeszult: _shares' — the reserved key reached Hungarian
customer/operator copy.
Mapped at the SOURCE of the notification (RunSharesTier2's tier2Notify calls) so no
future notifier wiring can reintroduce it, plus DisplayStackName at the main.go
wiring as idempotent defense in depth. Regression test added with a red-proof.
The samba_classify.go SCOPE NOTE and the README KNOWN GAP both described a gap that
R-7b closes; both now describe the sibling-shares-source execution instead.
Part 4 — restore: RestoreSharesScratch + PlaceSharesRestore as SIBLINGS of the
per-app scratch/place pair. Files merged missing-only (never overwriting), each
destination PREFIX-ASSERTED against registered LIVE storage roots; definitions
merged with existing-wins; ReconcileSamba via a seam (backup must not import
stacks); credential restored best-effort into the samba named volume.
New routes POST /backup/shares/{restore,place} + a restore-page entry that renders
'Megosztasok', never the raw reserved key.
Also adds scratchJoin: reconstructing an absolute captured path under a scratch
must strip the volume name rather than rely on filepath.Join.
Part 5 — liveness: EffectiveProtected gains a settings-backed dynamic extra so the
samba CONTAINER (not the stack name — they differ) is watched exactly while sharing
is on. FINDING: the issue -> health 'fail' -> existing health_critical event ->
alert -> Hungarian degradation e-mail path needs NO further change, and introduces
no new event type, so the allowlist gotcha does not apply.
Part 6 — UI: per-tier backup status lines on the Megosztas page (amber only on
deviation). Verified the two warning-prose sites (offbox_capture/tier2_capture)
only ever receive per-app stack names, so no mapping is needed there.
RED-PROOFS RUN AND REVERTED (both fired):
4. prefix-assert removed -> place-guard traversal test FAILS
5. dynamic samba extra removed -> Scenario E enabled-case FAILS
ONE additional restic call tagged [felhom-offbox, _shares] carrying the payload
staging dir + every mandatory share folder. Hooked into runOffboxInternal AFTER the
per-app loop and BEFORE retention, so forget --group-by host,tags covers the _shares
group with no flag change. Reuses resticStep, the caller's repo-ensure and
single-flight, and the SAME enlargement-gate arithmetic.
- quota gate degrades the push to MANIFEST-ONLY, never to nothing
- EnlargedBlocked keeps the RAW _shares key (templates index by it); the display
mapping applies only at the notification + Hungarian-prose boundaries
- OffboxTarget gains SharesLastRun/Status/Count for per-tier page truth
- zero-toggle notice suppressed when the shares leg provided coverage
- reserved-name defense: an app keyed _shares is excluded from the run loudly
RED-PROOFS RUN AND REVERTED (all fired):
1. shares leg appends into the app's argv -> isolation test FAILS
2. mandatory->offsite mapping inverted -> Scenario A + B FAIL
3. manifest-only degradation dropped -> Scenario C FAILS
Sibling shares source for the local cross-drive tier. Reuses the tier2Mirror seam,
selectTier2TargetFrom (narrow source-drive seam extracted from selectTier2Target),
tier2ReconcileRoots (pure extraction), tier2SafeRemove, the marker-LAST discipline
and the recordTier2* helpers. Per-app paths are untouched.
- shares_payload.go: deterministic _shares-manifest.json + best-effort passdb capture
- tier2_shares.go: per-source-drive legs -> cross-drive target, payload, marker LAST
- infra.SambaContainerName/SambaPassdbVolume/Mount: single source of truth for the
container identity (renderer, stacks execs, backup execs, monitor all read it)
- RESERVED-NAME finding: ValidateSMBShareName did NOT exclude a leading underscore,
so "_shares" was an accepted share name. Now refused; RunAllTier2 additionally
skips a "_shares" stack loudly as defense in depth.
- fix: shareSourceDrive returned a slash-normalised path, which made the target
selector's source-drive equality check miss (a group could target its own drive)
Live validation caught it: the 'new folder' flow passed the storage root through
sharingResolvePath, which (correctly) refuses the drive root as a share target —
so share creation silently failed. sharingResolveStorageRoot accepts EXACTLY a
registered live root (strictly tighter) and is used only as the new-folder parent.
Regression test asserts both halves.
The /api/ subtree is routed on the main mux, so the browse case in the web
ServeHTTP switch was shadowed by the apiRouter catch-all and 401'd. Moved to
ServeSharingAPI behind RequireAuth+CsrfProtect, matching /api/storage/.
Found by live validation.
New top-nav category with the Halozati megosztas page: enable/server-name card,
household password, shares table (Nev/Mappa/Irasvedett/Felhomentes/Torles), and
a create flow (new folder under <storage>/shares or an existing folder via the
browse modal). Every customer path goes through sharingResolvePath: absolute ->
EvalSymlinks -> containment in a registered live storage root -> deny-listed
system subtree check -> is-a-directory. Refusals are UNIFORM so the picker is
never a filesystem oracle. Deny-list derived from ProtectedHDDPaths (provably a
subset); the drive root is an exact-match denial so user-data folders under it
stay shareable. samba infra metadata + i-share icon. Gates green.
ClassifiedBinds("samba") resolves from the shares registry instead of catalog
metadata (samba has no .felhom.yml; its binds are absolute share paths). [R4]
Offsite ON -> mandatory (offsite + tier-2); OFF -> optional (tier-2 only);
smb.conf/passdb never classified. Verified through the REAL ComputeCaptureSet
tier filter incl. the negative (optional NOT in offsite). Zero engine edits.
Part-4 Step-1 finding: tier-2 (RunTier2) short-circuits on os.Stat(unitDir)
BEFORE GetStackClassifiedBinds, and the offsite runner enumerates
settings.GetOffboxApps() — both are recovery-unit shaped, which a share-only
infra stack has not. Wiring share data into a live run is therefore more than an
enumeration tweak; reported as a design fork per the STOP clause, not improvised.
ensureSamba joins EnsureBaseStack after filebrowser, gated on SMB.Enabled
(cloudflared conditional precedent); reconcile is idempotent (unchanged config +
running container = ZERO compose calls, asserted via seam). Atomic tmp+fsync+
rename config writes. Password applied via smbpasswd on STDIN (never argv/log/
settings). Disable = compose down, volumes + folders KEPT. samba added to
IsProtectedStack in code (controller.yaml is golden-generated and predates it),
which also makes the app-backup loops correctly skip it.
SMBSettings + SMBShare registry in settings (password never stored — only UserSet);
NetBIOS-safe name validation. Pure infra renderers: RenderSambaConfig (hardened
global block: SMB2 floor, bind interfaces only=lo eth0, disable netbios=no, force
user block) + RenderSambaCompose (network_mode host, pinned image, :ro bind for
read-only shares, passdb volume). Exact smb.conf golden + CRUD/validation tests.
The customer sees the guest's current memory + allowed range on the Rendszer settings
page and resizes it. The controller proxies + maps the agent's machine code to Hungarian;
the agent (felhom-agent v0.90.0) enforces every bound and applies live (no reboot).
agentapi: GuestMemory + ResizeMemory; ruled 412 -> *MemoryRefusedError (code+bounds);
pre-0.90 agent 404 -> typed *StatusError. Capability: FeatureGuestMemoryResize +
featureMinAgent 0.90.0 + a featureProbes row (type-asserts GuestMemory so the shared
SupportProber/netAgent are untouched).
UI (internal/web/system_memory_handlers.go, settings_system.html): "Szerver memoria (RAM)"
card + number input; POST /api/system/memory/resize -> capability gate -> agent -> flash.
JS confirm only on shrink. Code->Hungarian map; agent-outdated hides the control;
agent-unreachable falls back to the guest's /proc/meminfo. Agent English never shown raw.
Tests: agentapi (decode, 404, refusal-code, capability table) + web handler (success/
below_usage_floor/agent_outdated). Gates + go build/vet/test all pass.
- F6: POST /api/storage/init runs format→mount→register as a DETACHED single-flight job (context.Background, netAddState shape) the wizard polls via GET /api/storage/init/status; 3-step progress; register-last marker-last crash-safety. Fixes the client-disconnect-aborts-mount bug. No agent change (chain reaches FileBrowser sync = controller-only). Red-proof TestStorageInit_DetachedSurvivesClientDisconnect.
- F7: storage_init/attach Vissza → /storage (was /settings). Test TestStorageWizardBackAnchors_PointToStorage.
report.Waiter holds a hanging GET against hub /api/v1/wait?gen=N (same hub
URL+key as the pusher). On a generation change it fires the v0.139.0
report.Trigger — nothing else; the report ACK delivers everything through the
unchanged machinery. No overall client timeout (held GET); per-request ctx
bounds a dead connection. First-observation records-not-fires; same-gen
timeout fires nothing; errors (incl. 404 from a pre-v0.58.0 hub) back off
5s->5min while the 15-min cycle reconciles. Wired beside the trigger under the
same hubPusher!=nil && Hub.Enabled gate.
Red-proof: disable the baseline branch -> first observation fires
(TestWaiter_FirstObservationRecordsNoFire), run-fail-reverted.
Copy soften: backups_remote/escrow "néhány másodperc, legfeljebb 15 perc".
Pairs with hub v0.58.0. Grounding:
felhom.eu/documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md
After a completed escrow ceremony the Távoli mentés page showed the yellow
"Helyreállítási kód szükséges" card for ~15 min until the next hub-report ACK
flipped pending→escrowed. Phase-0 diagnosis (read-only) = verdict A (report-cycle
lag), already resolved on the demo box (escrow_state:"escrowed"); hub Hypothesis B
verified false (SaveHostEscrow ON CONFLICT already clears stale_at on upload) → no
hub change.
- settings.OffboxTarget.CeremonyCompletedAt: stamped on the recovery-code claim,
zeroed on the auto-confirmer Flip + the deprecated manual confirm; persisted.
- web/handlers.go: offboxCeremonyWaitState + escrowCeremonyGraceWindow (35m).
- backups_remote.html: info "megerősítésre vár, legfeljebb 15 perc" card → warn
"a megerősítés nem érkezett meg" past the window. Existing branches untouched.
- backups_escrow.html: "Mi történik ezután?" note on the wizard's final step.
- Test web/escrow_wait_state_test.go (truth table + red-proof recorded in REPORT).
No scheduler/agent/hub/endpoint changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
Saving the notifications form with a blank email box while events are enabled
wiped the customer's hub-side alert address (SyncPreferences pushed empty) —
the 2026-07-15 demo incident. settingsNotificationsHandler now refuses that
save before SetNotificationPrefs + hub sync, re-renders a Hungarian error, and
repaints the submitted checkboxes. Empty email + zero events (clear-all) still
proceeds. Tests + red-proof (remove guard -> stored email wiped to empty).
SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only
ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected
userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared
resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment).
appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains
DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side
floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive
class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning.
All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
tier2_capture.go: classified apps get TierSecondary per-bind legs (paperless copy shrinks — export
drops); legacy apps keep the byte-identical resolver set. v2 relpath-mirroring layout
(backups/secondary/<stack>/{marker LAST, recovery-unit/, hdd/<rel>/, userdata/<rel>/}); N>1 native
(errTier2MultiDir/tier2AppDataName deleted). Migration=delete-and-rebuild + reconcile; all RemoveAll
via tier2SafeRemove (refuses outside backups/secondary/). SSD=state-only tier. selectTier2Target
never picks network storage (pinned+auto, F-6C-1). Restore reads v2 behind a marker gate.
Part 0: offbox_enlarge_blocked is a persisted one-time Load seed (opt-out sticks), not a getter
append. Part 0.5: offsite restore scratch prefers a local (non-network) path.
Full v2 test suite + all 10 §10 red-proofs verified. Destructive writes bounded to backups/secondary/.
PlaceOffsiteRestore: live target via raw GetStackHDDPath not AppNamespaceRoot (F-3a-1a: no SSD
merge; undeployed refused), placement headroom gate (F-3a-1b), stat pre-pass over all placements
before any copy (F-3a-4: no partial writes), scratch removed on success/kept on failure (F-3a-2).
mapOffsiteRestorePaths refuses the namespace root itself (F-3a-3).
Delivery chain: DefaultEnabledEvents + GetNotificationPrefs append-if-absent migration + settings
checkbox + handler slice; paired with hub v0.55.0 allowlist (no customerMessages entry — raw
dynamic message survives). +8 tests; all 6 controller §10 red-proofs verified.
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.
- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
(delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
now so Task 3 consumes a tested seam.
INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.
- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
previously copied nothing and left an empty media dir (scope="all" was saved by
the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
false; corrected in CHANGELOG + main.go export-adapter comment.
+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne